diff --git a/CHANGELOG.md b/CHANGELOG.md index 80652b6..2cd4d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ 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. - **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 diff --git a/CLAUDE.md b/CLAUDE.md index f3418c1..46f34f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -496,8 +496,9 @@ 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 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 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 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 diff --git a/meshterm/services/trace_runner.py b/meshterm/services/trace_runner.py index 149ff94..a0dcb6a 100644 --- a/meshterm/services/trace_runner.py +++ b/meshterm/services/trace_runner.py @@ -188,11 +188,22 @@ def make_name_key_resolver( The app-wide colour rule takes the hue of each name from the key of the node. Some places in the UI (the inline sender of a channel message, an ``@mention``, the origin of a route graph) have only a *name*. This resolver changes that name (casefolded) - back into a key. The contacts of the device win (their keys are canonical). Then the + back into a key. The contacts of the device give the canonical key of a node. Then the stored names of the recorder add the nodes that the companion never added as contacts. Thus a known node that is not a contact still gets its own hue. A name that - no node has resolves to ``None``. The caller renders it muted, because colour is only - for identities with a key. + 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 + 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. Args: contacts: The known contacts to resolve against (name → public key/key prefix). @@ -202,17 +213,29 @@ def make_name_key_resolver( 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. + for it, or ``None`` when the name matches no known node, or more than one. """ - keys: dict[str, str] = {} + # The ids of the nodes that use each name, one entry for each node. + claims: dict[str, list[str]] = {} + + def claim(name: str, ident: str, *, canonical: bool) -> None: + held = claims.setdefault(name.casefold(), []) + for i, other in enumerate(held): + if other.startswith(ident) or ident.startswith(other): + if canonical: + held[i] = ident # the key of a contact replaces the stored id + return + held.append(ident) + for node, name in (stored_names or {}).items(): ident = node.lower().removeprefix("0x") if name and ident: - keys.setdefault(name.casefold(), ident) + claim(name, ident, canonical=False) for c in contacts or []: ident = (c.public_key or c.key_prefix or "").lower().removeprefix("0x") if c.name and ident: - keys[c.name.casefold()] = ident # contacts win over stored names + claim(c.name, ident, canonical=True) + keys = {name: ids[0] for name, ids in claims.items() if len(ids) == 1} def key_of(name: str) -> str | None: if not name: diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 225873d..312407a 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -162,12 +162,12 @@ def test_path_text_dims_the_tail_from_dim_from() -> None: def test_make_name_key_resolver_contacts_win_over_stored_names() -> None: - """Contacts resolve first, stored advert names resolve strangers, and misses are None.""" + """Contacts give the canonical key, stored advert names resolve strangers, misses are None.""" from meshterm.core.models import Contact from meshterm.services.trace_runner import make_name_key_resolver contacts = [Contact(name="Alice", public_key="d4" + "0" * 62)] - stored = {"60aabbccdd11": "Bob", "77ee00112233": "Alice"} # an old stored Alice + stored = {"60aabbccdd11": "Bob", "d40000000000": "Alice"} # Alice's own stored id key_of = make_name_key_resolver(contacts, stored) assert key_of("Alice") == "d4" + "0" * 62 # the key of the contact, not the stored id assert key_of("alice") == "d4" + "0" * 62 # case-folded @@ -175,6 +175,27 @@ 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. + + 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). + """ + 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 + + # A stranger that we only heard is a claim too, and it makes the name of a contact unsure. + 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 + + def test_name_style_without_a_key_is_the_unknown_grey() -> None: """A keyless name has no hue, because colour is only for identities that have a key.""" from meshterm.ui.theme import name_style, node_style