A shared name goes to the newest key unless two keys used it within a week

Greying every name that two keys ever used was too strict: a reflash with an
erase makes a new key, and 34 names on this mesh have one, JP's own among
them. Smog went grey this morning because of two keys silent since September.

The newest key now gets the name, and the name is grey only when another key
used it within name_overlap_days (a new Display preference, "Shared names",
default 7) of it: two live claimants. A contact that was never heard never
wins. On this machine's real data, LOG.IC 3R goes to its active key 47368a7c,
Smog to fa331c83, and 8 of 883 names stay grey.

The stored names come with their times from the one scan node_names() already
ran (node_names_with_times), so opening a chat costs no second scan.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-11 09:49:21 -04:00
1 parent 28529d6b62
commit 9ae0947717
15 files changed
+224 -52

No files matched your search

+8 -5
View File
@@ -32,11 +32,14 @@ can change how things behave. It does not only add new things.
keys hold what only one screen has: `Retry` or `Resend`, and `Log in` on a room. The
`Day ↑` and `Day ↓` chips are gone. On the desktop, ^PgUp and ^PgDn still move from one
day to the next.
- **A name that two nodes use is grey.** A channel message names its sender only by the
name that the sender typed, and any node can use any name. When two nodes use the same
name, MeshTerm cannot know which one wrote the message. Thus the name is grey, as an
unknown node is, and ^L and ^O do not open anything for it. Before, MeshTerm chose one
of the two nodes, and the name got the colour, the map, and the page of that node.
- **A name that two nodes use at the same time is grey.** A channel message names its
sender only by the name that the sender typed, and any node can use any name. When two
nodes used the same name within a week, MeshTerm cannot know which one wrote the
message. Thus the name is grey, as an unknown node is, and ^L and ^O do not open
anything for it. When the old node has been quiet for longer than that, the name goes
to the node heard most recently, because a radio that is flashed again gets a new
identity. Before, MeshTerm chose one of the two nodes, often the old one. The new
**Shared names** preference on the Preferences page sets the week.
- **Chips show where a route starts and where it ends.** Where your terminal draws path
chips, the author of a message has a point on the right of its chip. A mention is a chip
with a notch on its left. The packet viewer, the live feed, the Messages list, and the
+5 -3
View File
@@ -496,9 +496,11 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
node's key (its first byte — any known prefix agrees) so a rename keeps the colour.
One rule on **every** platform; only the resolution changes (see **Platforms**).
A surface holding only a name resolves it first (`make_name_key_resolver`); a node no
key can place — an unresolved sender, a name two or more keys use (a name is a claim
anyone can make, so MeshTerm never picks between them; JP, 2026-10-11), a bare hash
standing in as a name, an `○` ring — takes `node.unknown`, THE light grey for an unidentified node, everywhere and on both
key can place — an unresolved sender, a name two keys used within `name_overlap_days`
of each other (a name is a claim anyone can make, so MeshTerm never picks between two
live claimants; outside that window the newest key wins, because a reflash makes a new
key routinely; JP, 2026-10-11), a bare hash standing in as a name, an `○` ring — takes
`node.unknown`, THE light grey for an unidentified node, everywhere and on both
platforms. Colour is reserved for keyed identities, never seeded from a name's
characters. `node.unknown` is deliberately not `muted`: muted is chrome and may sit a
step darker, while an unidentified node is content you can still act on. Our own node
+1
View File
@@ -1749,6 +1749,7 @@ full_width auto auto
color_depth auto auto How many colours to send this terminal (next launch)
console_setup auto auto Move to a better terminal when this console can't draw MeshTerm
console_font 6x12 6x12 Bigger text, or more rows on the handheld's panel
name_overlap_days 7 7 Grey a name two nodes used within this many days
log_level WARNING WARNING How much MeshTerm writes to its log file (next launch)
```
+10
View File
@@ -248,6 +248,16 @@ def utcnow() -> datetime:
#: "now" for as long as the false timestamp stays ahead of the wall clock.
ADVERT_FUTURE_SKEW_S = 300
#: How close together (in days) two keys must use one name before MeshTerm calls the name
#: unsure. A name is a label that the owner of a key chooses, and a new key for the same
#: name is usual on a mesh: a reflash with an erase makes a new identity. Thus the key heard
#: most recently gets the name. Only two keys that both used the name within this window
#: make it unsure, because then two nodes claim it at the same time (refer to
#: :func:`~meshterm.services.trace_runner.make_name_key_resolver`). Seven days, from the
#: history of a real mesh (JP, 2026-10-11): it kept the colour of each node that was given
#: a new key, and made grey the names that two active nodes shared.
NAME_OVERLAP_DAYS = 7
def advert_time(last_advert: object) -> datetime | None:
"""Change the Unix timestamp of an advert into a plausible UTC datetime, or ``None``.
+16
View File
@@ -54,6 +54,7 @@ from .advert_store import DEFAULT_QUIET_S, QUIET_CHOICES_S
from .atomicwrite import write_atomically
from .courier_store import DONE_CAP
from .geo import DEFAULT_VIEW_FRACTION
from .models import NAME_OVERLAP_DAYS
from .watch_store import ALERT_CAP, DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
if sys.version_info >= (3, 11):
@@ -488,6 +489,21 @@ PREFERENCES: tuple[PrefSpec, ...] = (
choices=_CONSOLE_FONT_CHOICES,
platforms=frozenset({"picocalc-lyra"}),
),
PrefSpec(
key="name_overlap_days",
label="Shared names",
help="Grey a name two nodes used within this many days",
group="Display",
value_type="int",
# A channel message gives only the name of its sender. When two keys used that
# name within this window, MeshTerm cannot know which one sent it, and the name
# is grey. Outside the window, the key heard most recently gets the name. A larger
# value makes more names grey. 0 always gives the name to the newest key.
default=NAME_OVERLAP_DAYS,
minimum=0,
maximum=3650,
unit="days",
),
# --- Diagnostics ---------------------------------------------------------------
PrefSpec(
key="log_level",
+19 -2
View File
@@ -1734,6 +1734,21 @@ class Repository:
The latest name that is not empty, keyed by the stored node id (the key
prefix of 12 hex digits).
"""
return self.node_names_with_times()[0]
def node_names_with_times(self) -> tuple[dict[str, str], dict[str, datetime]]:
"""The latest name of each node (as :meth:`node_names` gives it), and when it used it.
The time is the latest reception that carried the name, with the time from our
clock. The name resolver needs it to know which of two keys that use one name is
the one in use now (refer to
:func:`~meshterm.services.trace_runner.make_name_key_resolver`). The same scan
gives the two, because a second scan of all the history costs as much again, and
a screen that resolves names opens with it.
Returns:
``(names, times)``, each keyed by the stored node id. The times are aware UTC.
"""
# The SQLite bare-column-with-MAX guarantee: in a group with ``MAX(observed_at)``,
# the ungrouped ``name`` comes from the row that has the maximum. Thus one
# aggregate scan gives the latest name of each node, and Python does not walk all
@@ -1741,11 +1756,13 @@ class Repository:
# ``idx_observations_node`` row by row (one random-access read for each entry,
# which is measurably slower than the scan).
rows = self._conn.execute(
"SELECT node, name, MAX(observed_at) FROM observations NOT INDEXED "
"SELECT node, name, MAX(observed_at) AS last FROM observations NOT INDEXED "
"WHERE node IS NOT NULL AND name IS NOT NULL AND name != '' "
"AND kind != 'packet' GROUP BY node"
).fetchall()
return {row["node"]: row["name"] for row in rows}
names = {row["node"]: row["name"] for row in rows}
times = {row["node"]: datetime.fromisoformat(row["last"]) for row in rows}
return names, times
def last_heard_by_node(self) -> dict[str, datetime]:
"""The most recent reception of each node, with the time from our clock.
+64 -21
View File
@@ -12,8 +12,10 @@ this module.
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping
from datetime import datetime, timedelta, timezone
from ..core import preferences
from ..core.connection import Device
from ..core.models import Contact, NameKeyResolver, NodeResolver, TraceResult
@@ -182,6 +184,9 @@ def make_key_resolver(contacts: list[Contact] | None) -> NodeResolver:
def make_name_key_resolver(
contacts: list[Contact] | None,
stored_names: dict[str, str] | None = None,
*,
heard: Mapping[str, datetime] | None = None,
overlap_days: int | None = None,
) -> NameKeyResolver:
"""Build a resolver that finds the key of a display name.
@@ -194,48 +199,79 @@ def make_name_key_resolver(
no node has resolves to ``None``. The caller renders it in the grey of an unknown node,
because colour is only for identities with a key.
**A name that two or more nodes use also resolves to** ``None``. A name is a label that
the owner of a key chooses, and nothing stops a second key from taking it. A channel
**When more than one key uses a name, the key heard most recently gets it**, unless
another of those keys used the name within ``overlap_days`` of it. Then the name
resolves to ``None``. A name is a label that the owner of a key chooses. A new key for
the same name is usual: a reflash with an erase makes a new identity, and an old key
that is silent for weeks is the earlier identity of the same owner. But a channel
message carries only the name of its sender, as text that each holder of the channel
secret can write. Thus, when two keys use one name, MeshTerm cannot know which one
sent the message, and a choice between the two is a guess that the hue, the map (^L),
and the node page (^O) then show as a fact. Before, the last contact in the list won.
A second "LOG.IC 3R" with a new key then took the hue and the map of the first, which
nothing proved (JP, 2026-10-11). Two ids are one node when one is a prefix of the
other: the full key of a contact and the stored id of the same node are one claim, not
two.
secret can write. When two keys use the name at the same time, MeshTerm cannot know
which one sent the message, and a choice between the two is a guess that the hue, the
map (^L), and the node page (^O) then show as a fact.
This rule replaced two others. First, the last contact in the list won: a second
"LOG.IC 3R" with a new key then took the hue and the map of the first. Then each name
that two keys ever used was grey: an old key, silent since September, then made "Smog"
grey in October (JP, 2026-10-11). A key with no time (a contact that was never heard)
never wins against a key with a time. When no key of a name has a time, the name is
unsure. Two ids are one node when one is a prefix of the other: the full key of a
contact and the stored id of the same node are one claim, not two.
Args:
contacts: The known contacts to resolve against (name → public key/key prefix).
Their ``last_seen`` is when each was last heard.
stored_names: The latest advertised name of each stored node id in the recorder
(:meth:`~meshterm.persistence.repository.Repository.node_names`). This
function inverts it into a fallback from name → node id.
heard: When each stored node id last used its name
(:meth:`~meshterm.persistence.repository.Repository.node_names_with_times`).
overlap_days: The window, in days. ``None`` reads the ``name_overlap_days``
preference.
Returns:
A callable that takes a display name and returns the best key hex that we have
for it, or ``None`` when the name matches no known node, or more than one.
for it, or ``None`` when the name matches no known node, or two that use it at
the same time.
"""
# The ids of the nodes that use each name, one entry for each node.
claims: dict[str, list[str]] = {}
if overlap_days is None:
overlap_days = preferences.current().name_overlap_days
window = timedelta(days=max(0, overlap_days))
times = heard or {}
# The nodes that use each name: one [id, last time] entry for each node.
claims: dict[str, list[list]] = {}
def claim(name: str, ident: str, *, canonical: bool) -> None:
def claim(name: str, ident: str, when: datetime | None, *, canonical: bool) -> None:
held = claims.setdefault(name.casefold(), [])
for i, other in enumerate(held):
if other.startswith(ident) or ident.startswith(other):
when = _aware(when)
for entry in held:
if entry[0].startswith(ident) or ident.startswith(entry[0]):
if canonical:
held[i] = ident # the key of a contact replaces the stored id
entry[0] = ident # the key of a contact replaces the stored id
if when is not None and (entry[1] is None or when > entry[1]):
entry[1] = when
return
held.append(ident)
held.append([ident, when])
for node, name in (stored_names or {}).items():
ident = node.lower().removeprefix("0x")
if name and ident:
claim(name, ident, canonical=False)
claim(name, ident, times.get(node), canonical=False)
for c in contacts or []:
ident = (c.public_key or c.key_prefix or "").lower().removeprefix("0x")
if c.name and ident:
claim(c.name, ident, canonical=True)
keys = {name: ids[0] for name, ids in claims.items() if len(ids) == 1}
claim(c.name, ident, c.last_seen, canonical=True)
keys: dict[str, str] = {}
for name, held in claims.items():
if len(held) == 1:
keys[name] = held[0][0]
continue
dated = [entry for entry in held if entry[1] is not None]
if not dated:
continue # nothing tells which key uses the name now
newest = max(when for _ident, when in dated)
live = [ident for ident, when in dated if when >= newest - window]
if len(live) == 1:
keys[name] = live[0]
def key_of(name: str) -> str | None:
if not name:
@@ -245,6 +281,13 @@ def make_name_key_resolver(
return key_of
def _aware(when: datetime | None) -> datetime | None:
"""``when`` as an aware time. A time with no zone is UTC, as MeshTerm stores each time."""
if when is None or when.tzinfo is not None:
return when
return when.replace(tzinfo=timezone.utc)
def parse_trace_path(spec: str, contacts: list[Contact] | None = None) -> str:
"""Parse a path spec from the user into the hex path string that ``send_trace`` expects.
+2 -2
View File
@@ -227,8 +227,8 @@ class ChatTool(Tool):
# app-wide colour rule). A name that no contact and no stored advert has stays
# muted. A room post names its author by key, which resolves in the other
# direction.
stored_names = ctx.repo.node_names()
key_of = make_name_key_resolver(contacts, stored_names)
stored_names, named_at = ctx.repo.node_names_with_times()
key_of = make_name_key_resolver(contacts, stored_names, heard=named_at)
name_of = make_node_resolver(contacts, stored_names)
# List the contacts by recency: first the contacts with messages, with the newest
+4 -3
View File
@@ -1468,7 +1468,8 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
)
contacts = await ctx.devstate.contacts()
names = _contact_names(contacts)
key_of = trace_runner.make_name_key_resolver(contacts, ctx.repo.node_names())
stored_names, named_at = ctx.repo.node_names_with_times()
key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at)
async def send(text: str) -> ChatMessage | None:
if conversation.is_channel:
@@ -1727,10 +1728,10 @@ async def _make_paths_presenter(
session = ctx.ui.session
contacts = await ctx.devstate.contacts()
stored_names = ctx.repo.node_names()
stored_names, named_at = ctx.repo.node_names_with_times()
resolve = trace_runner.make_node_resolver(contacts, stored_names)
type_of = trace_runner.make_node_type_resolver(contacts)
key_of = trace_runner.make_name_key_resolver(contacts, stored_names)
key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at)
prefix_bytes = await ctx.devstate.routing_prefix_bytes()
self_name: str | None = None
self_key: str | None = None
+2 -2
View File
@@ -1022,10 +1022,10 @@ async def open_livefeed(ctx: AppContext) -> None:
# The contacts first, then each name that the recorder overheard, as the fallback.
# This is the rule of all of the app: a node that we can name never renders as a bare
# hash.
stored_names = ctx.repo.node_names()
stored_names, named_at = ctx.repo.node_names_with_times()
resolve = trace_runner.make_node_resolver(contacts, stored_names)
type_of = trace_runner.make_node_type_resolver(contacts)
key_of = trace_runner.make_name_key_resolver(contacts, stored_names)
key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at)
prefix_bytes = await ctx.devstate.routing_prefix_bytes()
# The cap is the only limit: the newest _FEED_CAP packets that were ever stored, also
+2 -2
View File
@@ -1406,7 +1406,7 @@ async def open_node_detail(
except Exception: # noqa: BLE001 - optional reads. Safe defaults keep the page working.
prefix_bytes, width_bytes = 0, 1
stored_names = ctx.repo.node_names()
stored_names, named_at = ctx.repo.node_names_with_times()
# One resolver from hash to name for the full page. The route list and the route graph
# all name their hops through it (the contacts first, then the history of the recorder).
resolve = make_node_resolver(contacts, stored_names)
@@ -1559,7 +1559,7 @@ async def open_node_detail(
width_bytes,
resolve=resolve,
type_of=make_node_type_resolver(contacts),
key_of=make_name_key_resolver(contacts, stored_names),
key_of=make_name_key_resolver(contacts, stored_names, heard=named_at),
style=route_graph_style,
self_name=self_name,
node_label=label,
+2 -2
View File
@@ -404,7 +404,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
posts_only=True,
)
contacts = await ctx.devstate.contacts()
stored_names = ctx.repo.node_names()
stored_names, named_at = ctx.repo.node_names_with_times()
async def send(text: str) -> ChatMessage | None:
return await _with_restore(ctx, lambda: ctx.chat.send_post(room, text))
@@ -438,7 +438,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
access=ctx.rooms.access(room),
resend=resend,
paths=await _make_paths_presenter(ctx, conversation, device),
key_of=trace_runner.make_name_key_resolver(contacts, stored_names),
key_of=trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at),
locator=await Locator.gather(ctx),
pages=ContactPages(ctx, list(contacts)),
)
+3
View File
@@ -795,6 +795,9 @@ class _PickerRepo:
def node_names(self) -> dict:
return {}
def node_names_with_times(self) -> tuple[dict, dict]:
return {}, {}
def _chat_picker(cols: int, rows: int) -> Screen:
"""The conversation picker, built through the tool's own row funnel.
+19
View File
@@ -20,6 +20,25 @@ def repo(tmp_path: Path) -> Repository:
r.close()
def test_node_names_with_times_gives_each_latest_name_and_when_it_was_heard(
repo: Repository,
) -> None:
"""One scan gives the names and their times. ``node_names`` is the first half of it."""
from meshterm.core.models import Observation
run = repo.start_run("monitor", {})
early = datetime(2026, 9, 20, 12, 0, tzinfo=timezone.utc)
late = datetime(2026, 10, 10, 12, 0, tzinfo=timezone.utc)
repo.record_observation(run, Observation(node="aa11bb22cc33", name="Smog", observed_at=early))
repo.record_observation(run, Observation(node="aa11bb22cc33", name="Smog 2", observed_at=late))
repo.record_observation(run, Observation(node="dd44ee55ff66", name="Bob", observed_at=early))
names, times = repo.node_names_with_times()
assert names == {"aa11bb22cc33": "Smog 2", "dd44ee55ff66": "Bob"}
assert times == {"aa11bb22cc33": late, "dd44ee55ff66": early}
assert repo.node_names() == names
def _make_trace(
target: str,
*hops: tuple[str | None, float],
+67 -10
View File
@@ -175,25 +175,82 @@ def test_make_name_key_resolver_contacts_win_over_stored_names() -> None:
assert key_of("Zed") is None # no node has the name
def test_a_name_that_two_keys_use_resolves_to_no_key() -> None:
"""Two nodes with one name: MeshTerm cannot know which one a name means, so it is grey.
def test_a_name_that_two_keys_use_at_the_same_time_resolves_to_no_key() -> None:
"""Two nodes that used one name within the window: MeshTerm cannot tell them apart.
A channel message carries only the name of its sender. A second key that takes a name
must not get the hue, the map, and the page of the first (JP, 2026-10-11).
while the first is still in use must not get the hue, the map, and the page of either.
"""
from datetime import datetime, timedelta, timezone
from meshterm.core.models import Contact
from meshterm.services.trace_runner import make_name_key_resolver
old = Contact(name="LOG.IC 3R", public_key="5d" + "0" * 62)
new = Contact(name="LOG.IC 3R", public_key="47" + "0" * 62)
assert make_name_key_resolver([old, new])("LOG.IC 3R") is None
assert make_name_key_resolver([new, old])("log.ic 3r") is None # the order does not matter
now = datetime(2026, 10, 11, 12, 0, tzinfo=timezone.utc)
old = Contact(name="LOG.IC 3R", public_key="5d" + "0" * 62, last_seen=now - timedelta(days=3))
new = Contact(name="LOG.IC 3R", public_key="47" + "0" * 62, last_seen=now)
assert make_name_key_resolver([old, new], overlap_days=7)("LOG.IC 3R") is None
assert make_name_key_resolver([new, old], overlap_days=7)("log.ic 3r") is None # any order
# A stranger that we only heard is a claim too, and it makes the name of a contact unsure.
alice = Contact(name="Alice", public_key="d4" + "0" * 62, last_seen=now)
stranger = {"77ee00112233": "Alice"}
alice = Contact(name="Alice", public_key="d4" + "0" * 62)
assert make_name_key_resolver([alice], stranger)("Alice") is None
assert make_name_key_resolver([alice])("Alice") == "d4" + "0" * 62
heard = {"77ee00112233": now - timedelta(hours=1)}
assert make_name_key_resolver([alice], stranger, heard=heard, overlap_days=7)("Alice") is None
assert make_name_key_resolver([alice], overlap_days=7)("Alice") == "d4" + "0" * 62
def test_a_name_goes_to_the_newest_key_when_the_old_one_went_quiet() -> None:
"""A new key for a name is usual: a reflash makes a new identity (JP, 2026-10-11).
An old key that has been silent for longer than the window is the earlier identity of
the owner. It does not make the name unsure, and the newest key gets the name.
"""
from datetime import datetime, timedelta, timezone
from meshterm.core.models import Contact
from meshterm.services.trace_runner import make_name_key_resolver
now = datetime(2026, 10, 11, 12, 0, tzinfo=timezone.utc)
smog = Contact(name="Smog", public_key="fa" + "0" * 62, last_seen=now - timedelta(days=1))
september = {"def5e1786fff": "Smog", "caae7a09503d": "Smog"}
heard = {"def5e1786fff": now - timedelta(days=21), "caae7a09503d": now - timedelta(days=19)}
key_of = make_name_key_resolver([smog], september, heard=heard, overlap_days=7)
assert key_of("Smog") == "fa" + "0" * 62
# A wider window reaches the old keys again, and the name is unsure.
wide = make_name_key_resolver([smog], september, heard=heard, overlap_days=30)
assert wide("Smog") is None
# The window is measured from the newest key, whichever list it is in.
renamed = make_name_key_resolver(
[smog], {"0123456789ab": "Smog"}, heard={"0123456789ab": now}, overlap_days=0
)
assert renamed("Smog") == "0123456789ab"
def test_a_key_with_no_time_never_wins_and_no_times_at_all_is_unsure() -> None:
"""A contact that was never heard is not in use. With no time at all, nothing decides."""
from datetime import datetime, timezone
from meshterm.core.models import Contact
from meshterm.services.trace_runner import make_name_key_resolver
heard = Contact(
name="Bob", public_key="b0" + "0" * 62, last_seen=datetime(2026, 10, 1, tzinfo=timezone.utc)
)
never = Contact(name="Bob", public_key="c0" + "0" * 62)
assert make_name_key_resolver([never, heard], overlap_days=7)("Bob") == "b0" + "0" * 62
other = Contact(name="Bob", public_key="d0" + "0" * 62)
assert make_name_key_resolver([never, other], overlap_days=7)("Bob") is None
def test_the_window_is_the_name_overlap_preference() -> None:
"""With no window given, the resolver reads the preference: a value that a user can change."""
from meshterm.core.models import NAME_OVERLAP_DAYS
from meshterm.core.preferences import current
assert current().name_overlap_days == NAME_OVERLAP_DAYS == 7
def test_name_style_without_a_key_is_the_unknown_grey() -> None: