When a transport node repeats a data packet

Established from reference/Reticulum while fixing #383, where a node that was neither sender nor destination repeated all 30 probes it overheard on a shared medium and buried 24 of the 30 answers.

1. Which data packets the reference forwards at all

Transport.inbound reaches its path-table forwarding branch only through this condition (RNS/Transport.py:1559-1560):

if packet.transport_id != None and packet.packet_type != RNS.Packet.ANNOUNCE:
    if packet.transport_id == Transport.identity.hash:
        if packet.destination_hash in Transport.path_table:

Three gates in order: the packet must carry a transport id, that id must be this node's own identity hash, and a path to the final destination must be known. The complementary case is handled earlier, in Transport.packet_filter (Transport.py:1341-1344): a non-announce packet whose transport id names a different instance is rejected before any of this runs. So a transport node acts on exactly one class of overheard traffic, the class addressed to it by name.

Once inside, the hop count only selects the header rewrite (Transport.py:1567-1581): remaining_hops > 1 keeps HEADER_2 and swaps in the next hop, remaining_hops == 1 strips back to HEADER_1, remaining_hops == 0 just bumps the count. All three transmit.

A packet addressed directly to a destination one hop away is never handed to a transport node. The sender decides this, in Transport.outbound (Transport.py:1134-1166): a path table entry with hops > 1, or hops == 1 while the sender is behind a shared instance, gets the HEADER_2 transport header with the next hop written into it. Anything else falls through to

# If none of the above applies, we know the destination is
# directly reachable, and also on which interface, so we
# simply transmit the packet directly on that one.

which puts HEADER_1 on the air with no transport id. That packet fails the very first gate at Transport.py:1559 on every node that hears it, so a neighbour holding its own path to the destination repeats nothing. Holding a path is not an invitation to forward; being named is.

The one exception, and it is not really an exception: if the destination sits behind a local client of a shared instance, the previous hop stripped the transport id (clients are made to look directly reachable), so the instance synthesizes it back before the gate (Transport.py:1543-1548):

if packet.transport_id == None and for_local_client:
    packet.transport_id = Transport.identity.hash

for_local_client is a path table entry at hops == 0 (Transport.py:1513).

2. What the reference does repeat without being named

Two mechanisms, and a fix to the path-table gate must leave both alone.

Link table (Transport.py:1648-1686). Packets addressed to an established link's id carry no transport id at all, and the relay repeats them purely off its link_table entry. This is not overhearing: the entry exists only because this node forwarded the LINKREQUEST earlier, as its designated next hop (Transport.py:1625). The same-interface case is explicit about repeating back onto the medium the packet arrived on:

# If receiving and outbound interface is
# the same for this link, direction doesn't
# matter, and we simply repeat the packet.

gated on the taken hop count matching one of the two frozen counts, which is what stops the relay-to-relay echo on one channel.

The hop counts are the whole gate. IDX_LT_VALIDATED is not consulted here -- expiry is its only reader (Transport.py:687) -- so a relay that forwarded the LINKREQUEST but lost the returning LRPROOF still carries the link's data. Gating the repeat on it instead would strand a link its two endpoints consider established, on one relay's RF luck (Codeberg #228).

Announces. Rebroadcast is transport-id-independent by design; the packet_filter exemption at Transport.py:1342 exists for it.

3. Shared versus point-to-point

Nothing in any of this inspects whether the interface is a shared medium. The gate is a property of the packet, not of the carrier, and it produces the right behaviour on both: on a point-to-point link the only node that hears the packet is the one it was sent to, so the gate never fires; on a shared medium it is the only thing standing between one probe and N repeats.

The one interface comparison in the area, link_entry[IDX_LT_NH_IF] == link_entry[IDX_LT_RCVD_IF], compares two stored interface indices of one link entry. It asks whether a relayed link happens to enter and leave by the same interface, not whether that interface is shared.

Consequences for us

leviculum-core/src/transport.rs, handle_data, now gates the path-table forward on transport_id == Some(own hash), with the for_local synthesis arm. The LINKREQUEST path already carried the same gate (designated_hop), added for the LRPROOF echo storm; data never got it.

Path::needs_relay() (storage_types.rs:60, hops > 1 && next_hop.is_some()) is not the predicate for this and never was. It answers how to rewrite the header of a packet already accepted for forwarding, mirroring the remaining_hops split above. Gating acceptance on it would kill the last hop of every chain: in A-B-C where A cannot hear C, B is correctly the designated hop and B's path onward to C is exactly one hop, so needs_relay() is false for the very packet B must repeat.

Pinned by leviculum-core/src/node/mvr_overheard_direct_data.rs, whose control 2 is that chain packet.