diff --git a/SPEC.md b/SPEC.md index 26e445c..d040e80 100644 --- a/SPEC.md +++ b/SPEC.md @@ -712,12 +712,112 @@ A `path?` request itself is a regular DATA packet (verified by `tools/verify_pat **Every node — including non-transport leaf clients — that knows the requested target MUST respond by re-announcing.** This is the only way the requester learns a path back. If you implement only the "send a path request" half but not the "respond to incoming requests for our own destination" half, peers can never message you after the path expires (typically within minutes after your last announce). -The minimum responsibility for a non-transport leaf: +#### 7.2.1 Path-request packet parse rules -1. Detect inbound DATA packets with `dest_hash == path_request_dest`. -2. Parse first 16 bytes of payload as `target_hash`. -3. If `target_hash == our_destination_hash`, immediately call `sendAnnounce()`. -4. Otherwise (target is some other destination), do nothing — leaf clients can't fulfill path requests for destinations they don't OWN. +The path-request handler at `RNS/Transport.py:2800-2843` parses inbound packets addressed to `path_request_destination` (the dest_hash in §7.1). The handler is registered as the destination's `packet_callback` at `Transport.py:237-240`, so any DATA packet to that dest_hash flows through it. + +```python +def path_request_handler(data, packet): + if len(data) >= 16: + destination_hash = data[:16] # mandatory 16B target + if len(data) > 32: + requesting_transport_instance = data[16:32] # optional 16B transport_id + else: + requesting_transport_instance = None + + # tag bytes — required, anything past the fixed prefix + tag_bytes = data[32:] if len(data) > 32 else (data[16:] if len(data) > 16 else None) + if tag_bytes is None: # tagless requests are dropped + return + if len(tag_bytes) > 16: + tag_bytes = tag_bytes[:16] # cap to 16B +``` + +Three observations that matter for interop: + +1. **Tagless requests are dropped.** A path? packet with exactly 16 bytes payload (just `target_dest_hash`, no tag) is logged at DEBUG level and discarded. The tag is what makes the request unique enough to dedup — without it, a relay would loop forever on retransmits of the same packet. A clean-room implementation MUST emit at least one tag byte; the upstream emitter (`RNS.Transport.request_path`) uses 16 random bytes. +2. **The transport_id field is optional and detected by length.** If the payload is exactly 32 bytes the second 16B slot is the tag; if it's >32 bytes the second 16B is `transport_id` and the rest is the tag. This is consistent with the §7.1 description (leaf: 32B; transport: 48B) but the boundary case `len == 32` lands in the leaf-client interpretation. +3. **The tag is capped at 16 bytes.** Any tail beyond that is silently truncated. Senders may emit longer tags but receivers normalize to 16B for dedup table keys. + +#### 7.2.2 Tag-based deduplication + +The handler builds `unique_tag = destination_hash || tag_bytes` and consults `Transport.discovery_pr_tags` (`Transport.py:2829-2839`): + +```python +unique_tag = destination_hash + tag_bytes + +with Transport.discovery_pr_tags_lock: + if not unique_tag in Transport.discovery_pr_tags: + Transport.discovery_pr_tags.append(unique_tag) + Transport.path_request(destination_hash, + from_local_client(packet), + packet.receiving_interface, + requestor_transport_id=requesting_transport_instance, + tag=tag_bytes) + else: + # ignore duplicate path request +``` + +`discovery_pr_tags` is bounded at `Transport.max_pr_tags = 32000` entries (`Transport.py:126`); older entries are aged out by the periodic `Transport.jobs` cycle. **Every node — leaf or transport — that wants to respond to path requests MUST maintain this dedup table** or it will respond to every retransmit, and a transport-enabled node will additionally re-forward to all other interfaces, generating a broadcast storm. + +The `unique_tag = dest_hash || tag` format means the same tag bytes against different destination_hashes are distinct — so two different requesters racing for the same target with happenstance-identical random tags don't suppress each other. Senders MUST use a fresh random tag per fresh request (the upstream emitter calls `Identity.get_random_hash()`); reusing tags across requests for the same destination_hash makes the second request appear to be a duplicate. + +#### 7.2.3 The five-way dispatch in `Transport.path_request` + +`RNS/Transport.py:2846-2973`. After dedup, the handler calls into `path_request()` which decides how to respond. Five mutually-exclusive branches in priority order: + +1. **`destination_hash` is local** (i.e. it's one of our own registered destinations, line 2873-2875): + ```python + local_destination.announce(path_response=True, tag=tag, + attached_interface=attached_interface) + ``` + We answer by emitting a path-response announce (§7.2.4 below) on the interface the request arrived on. **This is the only branch a leaf client must implement** — the others are transport-mode behaviours. + +2. **Path is known via the path_table AND `(transport_enabled OR is_from_local_client)`** (line 2877-2938): retrieve the cached announce packet from the path table, set its hops to the cached value, and queue it for retransmit. If the next hop happens to be the requestor itself (path-loop indicator), drop instead. This is the transport-mode path-resolver: a relay that already knows where the destination lives answers on its behalf, saving the requester from another hop of broadcast. + +3. **Request is from a local-client interface, no path known** (line 2940-2947): forward the request to every OTHER interface so the broader mesh can answer. Generates a fresh random tag for the forwarded request to avoid loop-back through the same dedup table. + +4. **`transport_enabled` AND no path known AND interface allows discovery** (line 2949-2963): record a `discovery_path_requests` entry (capped at `PATH_REQUEST_TIMEOUT = 15s`) and forward the request to every other interface, **preserving the original tag** to prevent loops. This is recursive transport-mode discovery — we don't know the destination but we'll go ask the rest of the mesh. + +5. **No path known and not transport-enabled** (line 2972-2973): log "no path known" and drop. Leaf clients hit this branch when they receive a path? for someone else's destination. + +Branch 1 is the only MUST for any node that wants to be reachable. Branches 2-4 are transport-node behaviours; a leaf client safely ignores them by never being in `transport_enabled` mode. + +#### 7.2.4 Path-response announce wire format + +When branch 1 fires, `Destination.announce(path_response=True, tag=tag, ...)` runs. The wire bytes are **identical to a regular announce (§4.1)** except the outer Reticulum packet's context byte is set to `PATH_RESPONSE = 0x0B` instead of `NONE = 0x00` (`RNS/Destination.py:307-308`): + +```python +if path_response: announce_context = RNS.Packet.PATH_RESPONSE +else: announce_context = RNS.Packet.NONE +``` + +The body — public_key || name_hash || random_hash || [ratchet_pub] || signature || app_data — is built identically; the random_hash carries a fresh emission timestamp, the signature is computed over the same signed_data per §4.2. A receiver running the validation flow in §4.5 can't tell from the announce body that this is a response to a query rather than a periodic re-announce; only the context byte distinguishes them. + +A `tag` argument hands a previously-built path-response announce body back unchanged when the same tag is requested twice within `Destination.PR_TAG_WINDOW = 30s` (`RNS/Destination.py:260-278`). This is what prevents a flood of identical path-response announces when several relays simultaneously forward the same path? request to a leaf — the leaf serves the cached body to all of them with the same wire bytes, lining up dedup decisions on every transit relay. + +#### 7.2.5 Timing: `PATH_REQUEST_GRACE` and roaming + +When branch 2 fires (transit relay answering on behalf of a remote destination), the rebroadcast is delayed by `PATH_REQUEST_GRACE = 0.4s` (`Transport.py:80, 2917`) — extra grace to let directly-reachable peers respond first if they're in earshot. On `MODE_ROAMING` interfaces an additional `PATH_REQUEST_RG = 1.5s` is added on top (`Transport.py:81, 2922-2923`) so well-connected fixed nodes get a chance to answer before mobile ones. + +Branch 1 (local destination answers) fires immediately with no grace, since the leaf is the authoritative source for its own destination — there's no point waiting for someone else to potentially answer faster. + +Local-client originators also bypass the grace period (`Transport.py:2909-2910`): a relay answering for a destination that lives on a local-client interface can send back the cached announce instantly because the answer doesn't need to compete with peer-mesh announces. + +#### 7.2.6 Minimum responsibility for a leaf + +The minimum path-request response logic for a non-transport leaf, in protocol terms: + +1. Receive a DATA packet with `dest_hash == 6b9f66014d9853faab220fba47d02761`. +2. Parse `target_dest_hash = data[:16]` and `tag_bytes = data[16:32]` (or `data[32:48]` if `len(data) > 32`). +3. Drop if `len(tag_bytes) == 0` (tagless requests). +4. Drop if `(target_dest_hash, tag_bytes)` already in the dedup table. +5. If `target_dest_hash == our_destination_hash` for any of our registered destinations: emit a path-response announce (§7.2.4) on the receiving interface, with the request's tag passed through to allow caching. +6. Otherwise: do nothing — leaves can't fulfill path requests for destinations they don't OWN. + +Steps 4 and 5 are both required. Skipping the dedup table makes the leaf storm the network with redundant announces; skipping the local-destination check means peers can never message you after the path expires. + +For a chronological walk-through of the full request → response → path-table cycle, see [`flows/path-discovery.md`](flows/path-discovery.md). ### 7.3 Ratchet rotation per announce diff --git a/flows/README.md b/flows/README.md index 39b9753..81312a1 100644 --- a/flows/README.md +++ b/flows/README.md @@ -13,12 +13,12 @@ The two views are complementary: SPEC.md tells you what each piece looks like; t | [`send-link-lxmf.md`](send-link-lxmf.md) (DIRECT method, over a Reticulum Link) | ✅ | | [`receive-announce.md`](receive-announce.md) | ✅ | | [`send-resource.md`](send-resource.md) (Resource fragmentation over a Link) | ✅ | +| [`path-discovery.md`](path-discovery.md) (path? request, path-response wire detail, path-table population) | ✅ | | `receive-resource.md` (inverse of send-resource: ADV ingestion, part assembly, proof emission) | ⏳ | | `receive-link-lxmf.md` (inverse of send-link-lxmf, including responder side of the handshake) | ⏳ | | `send-propagated-lxmf.md` (PROPAGATED method, via a propagation node) | ⏳ | | `send-announce.md` (build, sign, transmit, ratchet rotation, periodic re-announce) | ⏳ | | `forward-announce.md` (transport-node rebroadcast logic, announce_cap, queue) | ⏳ | -| `path-discovery.md` (path? request, path-response wire detail, path-table population) | ⏳ | ## Conventions diff --git a/flows/path-discovery.md b/flows/path-discovery.md new file mode 100644 index 0000000..93fcf56 --- /dev/null +++ b/flows/path-discovery.md @@ -0,0 +1,199 @@ +# 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.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: + +```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` (`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`: + +```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 | diff --git a/todo.md b/todo.md index 1abe241..a070ff9 100644 --- a/todo.md +++ b/todo.md @@ -189,16 +189,21 @@ re-research. handshakes when one side emits signalling and the other doesn't. §6.1 and §6.2 inline references updated to point at §6.6 for the bit layout. Existing §6.6 "Source" renamed to §6.7. -- [ ] **SPEC.md §7.2 expansion + new flow `flows/path-discovery.md`: - path-response announce vs periodic announce.** When a node - fulfills a `path?` request it emits an announce with - `path_response=True`, which sets `context = PATH_RESPONSE = 0x0B` - on the announce packet (`RNS/Packet.py:83`). Receivers - distinguish via `packet.context == RNS.Packet.PATH_RESPONSE` - (`RNS/Transport.py:1989-1991`); announce handlers default to - ignoring path-responses unless they set - `receive_path_responses = True` on themselves. Spec mentions §7.2 - "respond by re-announcing" but doesn't name the wire context. +- [x] **SPEC.md §7.2 expansion + new flow `flows/path-discovery.md`: + path-response announce vs periodic announce.** Done. SPEC.md + §7.2 now has six sub-sections: parse rules for the path-request + packet, tag-based dedup via `discovery_pr_tags`, the five-way + dispatch in `Transport.path_request` (local responder / + transit-knows-path / local-client-forward / discovery-recursive + / drop), the path-response announce wire format (regular + announce body + `context = PATH_RESPONSE = 0x0B`), the + `PR_TAG_WINDOW = 30s` body-cache mechanism that lets multiple + relays receive the same wire bytes for dedup convergence, + timing rules (`PATH_REQUEST_GRACE = 0.4s` + `PATH_REQUEST_RG = + 1.5s` for roaming-mode), and a minimum-leaf-responsibility + summary. `flows/path-discovery.md` walks the 9-step chronology + with two wire-byte ladders (single-hop leaf-owns-target and + two-hop transit-relay-knows-path). - [ ] **SPEC.md §1.3 expansion: identity on-disk format.** §1.3 names the byte order (Ed25519 first, X25519 second, opposite of the public-key concat) but not the file structure. `RNS/Identity.py::to_file`