From 27eac3d4e548f7dfe5a27349e078b27167f92541 Mon Sep 17 00:00:00 2001 From: Afri <220195275+l5yth@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:06:43 +0200 Subject: [PATCH] web,data: stop Reticulum destination placeholders from renaming (#968) --- CHANGELOG.md | 1 + data/mesh_ingestor/CONTRACTS.md | 7 + data/mesh_ingestor/protocols/reticulum.py | 9 +- tests/test_reticulum_unit.py | 73 ++++++-- .../application/data_processing/identity.rb | 58 +++++- .../data_processing/node_writes.rb | 60 ++++--- web/spec/reticulum_spec.rb | 166 +++++++++++++++++- 7 files changed, 331 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87314c38..edbb6e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ * CI: the Python workflow tests Python 3.13 and 3.14; neither CI matrix fails fast (SPEC DP5) * Web: a MeshCore channel message heard by two ingestors that resolved its sender to different node ids is stored once (#880) * Web: the first boot after upgrading deletes duplicate MeshCore messages once; on instances with several MeshCore ingestors this can remove about a fifth of the stored MeshCore messages (#880) +* Data/Web: a Reticulum destination's placeholder name never replaces its real name or names its node; a node with no announced name reads its own placeholder; a nameless destination is named from its own hash (SPEC RA10, RE10) * Web, bridge, app: Reticulum is named as its own protocol in the bridge tag (`[RT]`), the footer join strip (`RETICULUM_PRESET` and `RETICULUM_FREQ`), the `/nodes` page description and the client fallback label; the site description names no protocol; the app README states the app is Meshtastic only (SPEC RL8) * Docs: Reticulum is marked experimental and announce-only; the README says what the RNS stack transmits and that the channel filters ignore it (SPEC RD7/RN5) * Nix: the flake ingestor includes `rns`, so `PROTOCOL=reticulum` runs on NixOS; `flake.lock` moves nixpkgs to nixos-unstable of 2026-10-03, which brings Python 3.14 and Ruby 3.4.9 (SPEC DP8) diff --git a/data/mesh_ingestor/CONTRACTS.md b/data/mesh_ingestor/CONTRACTS.md index 98fa1f2a..50d81960 100644 --- a/data/mesh_ingestor/CONTRACTS.md +++ b/data/mesh_ingestor/CONTRACTS.md @@ -484,6 +484,13 @@ One row per announced destination, newest `last_heard` first. body the cursor produced. - Fields: `id` (destination hash, hex), `node_id`, `identity_hash`, `name`, `aspect`, `role`, `interface`, `first_heard`, `last_heard`, `protocol`. +- `name` is the destination's announced display name, else `Reticulum` plus the + upper-cased first four hex of its own `id`, the placeholder the ingestor sends + for a nameless destination on the announce and host paths alike (SPEC RA10). + A destination's placeholder is stored on a first sighting, never replaces a + stored name and never becomes the node's `long_name`, which is the + highest-ranked announced name, else a stored name that is not a placeholder, + else the node's own placeholder (SPEC RE10). - `identity_hash` groups rows belonging to one peer; several rows share it when an identity announces on several aspects. - Written only by the node ingest route, from each node record's `destination` diff --git a/data/mesh_ingestor/protocols/reticulum.py b/data/mesh_ingestor/protocols/reticulum.py index 5e92cf2c..0f91c86a 100644 --- a/data/mesh_ingestor/protocols/reticulum.py +++ b/data/mesh_ingestor/protocols/reticulum.py @@ -600,9 +600,16 @@ def _announce_to_node_dict( return None hash_hex = _reticulum_hash_hex(dest_hash) display_name = _decode_display_name(app_data) + # The web stores this name on the destination row, so a placeholder is + # built from the destination's own hash, as host discovery builds one + # (SPEC RA10(b)). The node id stands in only for an unusable hash, which + # writes no destination row; the node's headline comes from RE10. + placeholder_id = _reticulum_node_id(dest_hash) or node_id user: dict = { "longName": ( - display_name if display_name else _reticulum_placeholder_name(node_id) + display_name + if display_name + else _reticulum_placeholder_name(placeholder_id) ), "shortName": _reticulum_short_name(node_id), "publicKey": _identity_public_key_hex(identity), diff --git a/tests/test_reticulum_unit.py b/tests/test_reticulum_unit.py index e1eda4eb..bdea3020 100644 --- a/tests/test_reticulum_unit.py +++ b/tests/test_reticulum_unit.py @@ -460,22 +460,24 @@ def test_fallback_name_matches_the_web_placeholder_for_every_id_shape(): mismatch that breaks ~85% of the id space — which is exactly how the first attempt at this fix passed its own test while still clobbering names. """ - for node_id, expected in ( + identity = _FakeIdentity(bytes.fromhex(_FIELD_PRIMARY)) + for dest_ref, expected in ( ("!0001beef", "Reticulum 0001"), # digits only - matches even lower-cased ("!c0ffee00", "Reticulum C0FF"), ("!beefcafe", "Reticulum BEEF"), ("!deadbeef", "Reticulum DEAD"), ("!0000000a", "Reticulum 0000"), ): - # The placeholder names the row, and the row is keyed on the - # identity — so walk identity hashes, not destinations. The expected + # The placeholder names the row it lands on, the destination (SPEC + # RA10(b)), so walk destination hashes under one identity. The expected # strings below are the *web tier's* rule (placeholder_short_id in - # identity.rb): first four hex, upper-cased, for reticulum. If the two - # sides drift, the web upsert stops recognising these as placeholders - # and they start overwriting real display names. - idn = _FakeIdentity(bytes.fromhex(node_id[1:] + "11" * 12)) - node = _announce_to_node_dict(_DEST_HASH, None, identity=idn, last_heard=1) - assert node["user"]["longName"] == expected, node_id + # identity.rb, which upsert_destination applies to "!" plus the first + # eight hex of the destination): first four hex, upper-cased, for + # reticulum. If the two sides drift, the web upsert stops recognising + # these as placeholders and they start overwriting real display names. + dest = bytes.fromhex(dest_ref[1:] + "11" * 12) + node = _announce_to_node_dict(dest, None, identity=identity, last_heard=1) + assert node["user"]["longName"] == expected, dest_ref # --------------------------------------------------------------------------- @@ -742,8 +744,11 @@ def test_host_destinations_carry_their_interface_and_announced_name(monkeypatch) def test_host_destination_falls_back_to_the_placeholder_without_a_name(monkeypatch): """A destination the stack has never heard a name for keeps the placeholder. - The generic name is wanted -- it is the web upsert's recognised fallback - form -- but only where there is no real one to prefer. + The generic name is wanted, but only where there is no real one to prefer. + The web upsert recognises both placeholder forms, the node id's head and the + destination's own head, so it stores this one on a first sighting, never + lets it replace a real name, and never lets it head the node, which keeps + its own ``Reticulum 2771`` (web/spec/reticulum_spec.rb pins the same string). """ _local_stack(monkeypatch, {_FIELD_LXMF: _FIELD_PRIMARY}) record = ReticulumProvider().host_destination_nodes()[0] @@ -754,6 +759,28 @@ def test_host_destination_falls_back_to_the_placeholder_without_a_name(monkeypat assert "interface" not in record +def test_peer_destination_placeholder_names_its_own_hash(): + """A nameless peer announce names its destination from that destination. + + SPEC RA10(b): each row is named from its own hash. The host path above + does this; the announce path built the fallback from the identity's node + id, so the field peer's ``lxmf.delivery`` destination ``4cf985bf...`` was + stored as ``Reticulum 2771``, the node's name, instead of its own + ``Reticulum 4CF9``. The node keeps its own short name (RA10(a)). + """ + node = _announce_to_node_dict( + bytes.fromhex(_FIELD_LXMF), + None, + identity=_FakeIdentity(bytes.fromhex(_FIELD_PRIMARY)), + aspect="lxmf.delivery", + last_heard=1, + ) + assert node["nodeId"] == "!27716218" + assert node["user"]["shortName"] == "2771" + assert node["destination"]["id"] == _FIELD_LXMF + assert node["user"]["longName"] == "Reticulum 4CF9" + + def test_transport_aspect_is_gated_on_transport_enabled(monkeypatch): """TRANSPORT is reported only when the stack actually relays traffic. @@ -886,16 +913,23 @@ def test_announce_to_node_dict_public_key_none_when_unreadable(): assert node["user"]["publicKey"] is None -def test_announce_to_node_dict_long_name_falls_back_to_the_node_placeholder(): - """Undecodable app_data falls back to a placeholder naming the *node*. +def test_announce_to_node_dict_long_name_falls_back_to_its_destination_placeholder(): + """Undecodable app_data falls back to a placeholder naming the destination. - Not the destination: the row is keyed on the identity (SPEC RE7), and the - web upsert only yields to the "