#1075: the contact, channel, message and channel-sender menus were plain
Columns in a sheet capped at 9/16 of the screen, so items past the cap were
clipped. A room server's Delete Contact was unreachable on an iPhone. They
now scroll, and the sheet can grow to the safe area.
#1074: a message with an @mention or contact card was built as rich text
whose plain segments were never linkified. Those segments now go through
LinkifySpan with the same options, style and confirm dialog as Linkify.
Three new widget tests fail without the fix and pass with it.
Epic #1078.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Apple stores the owner's standard-encryption answer as false with no declaration, on all three answered builds, so the key restates that answer and later builds should skip the question. Corrects finding 14, which said false would contradict it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iOS has no system Back. The chat had a hamburger in place of the back arrow, PopScope(canPop: false) switched off the edge swipe, and the keyboard covered the bottom bar with nothing to dismiss it.
Add a back arrow to the channel and the line-of-sight map, keep the edge swipe on pushed screens on iOS, and dismiss the keyboard by tapping the channel's messages or dragging either chat's list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Set to true with no compliance code, validation failed with 90592 in run 41179059212. No code exists, because no document is required with France excluded, and false would contradict the answer given to Apple. The owner answers one question per build instead.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The published privacy policy lists it, and the notes should match.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A push trigger keyed to this exact branch, so the owner can test before the Epics merge. The apple-signing Environment admits this branch only for that pass, and the trigger comes out before this merges.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rebrand wrote Offband Meshcore, against the owner's capitalization. Launcher label, title, strings, notifications, window titles, web, release titles and docs are corrected. ProductName in Runner.rc deliberately keeps the old spelling: path_provider builds the Windows data folder from it, so changing it would orphan every Windows user's data. Bundle IDs, past release texts and the certificate subject are unchanged.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner decision for the first App Store submission. Guideline 2.5.19 bars hidden features, and a personal sponsorship link is an unclear fit. With no unlock on iOS, CoreScope is unreachable there, so the privacy label becomes Data Not Collected, and the notes no longer need the disclosure. Other platforms are unchanged.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The display name now matches the App Store record. The notes and listing state what block really does, including hiding channel senders, and say plainly that there is no report button. Verdicts are on #1014.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Within Apple's field limits as read today, with sizes from Apple's current specification.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Discloses the Experimental section, and covers user-generated content, the donation link and encryption.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CoreScope is declared per the plan's choice 2, and every other host is read against Apple's definition of collect.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App Store guideline 2.3.10 bars naming other marketplaces in an app. Other platforms keep the link.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner answered standard algorithms on build 41171670352, and it moved to Ready to Submit with no document. The answer is recorded on #958. The next build shows whether Apple still asks.
Epic #1014, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 41171670352 validated and uploaded 1.7.0 (41171670352), so the trigger has done its job. testflight.yml is back to manual dispatch only. The plan records what that run proved.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A push trigger keyed to this exact branch, for the first real upload. The apple-signing Environment admits this branch only for this run, and the trigger comes out once the lane is proven.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The assertion stayed green with the flag off, and run 41171197215 listed sqlite3.framework and llamadart.framework, so the plan's hypothesis was wrong. The flag is gone, the assertion still guards every build, and it is proven able to fail against an empty app. The plan now says so, and records the run ID as the build number. Negative steps discard their output so their error lines do not annotate a passing job.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ios-signed.yml cannot run on a pull request, so its output check is proven in the compile job with canned output and no secrets: exit 0 with an error, with mixed output, with no success line, and a non-zero exit.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both call ios-signed.yml. The release job deliberately does not need the ios job, so an Apple failure cannot block Android and desktop releases. Neither has a pull_request trigger.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reusable workflow in the apple-signing Environment. It checks secrets, profile, frameworks, version and signature, then validates and uploads with altool, and stops on anything it cannot confirm. There is no dry-run input. The build number is the run ID, because the two callers count run numbers separately. Review verdicts are on #1013.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iPhone-only and the Giphy define are in. Native assets are deliberately not enabled yet, so this push shows whether the assertion goes red without them, as the plan requires. The next commit enables them.
Epic #1013, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rotation now names -WorkDir with a new folder, and the script comment reports the legacy-format reason instead of stating it as fact. Verdicts are on #1012.
Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Apple refused app.offband.meshcore as not available and the owner's team does not hold it. The owner created the App Store Connect record under the .ios ID, so the runbook, TestFlight guide and Xcode project now match. Android, macOS and Linux keep their ID.
Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The script already takes -WorkDir; the runbook now says to pass it on every command and use that folder throughout.
Epic #1012, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner request. Apple account documents stay out of the public repository.
Epic #1012, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Apple portal steps checked against Apple's help pages (App ID, App Store profile, new app record, availability, DSA, API access). Paste-ready PowerShell for the script; rotation table. One open item tagged in the session record: the least-privileged API key role that can upload builds.
Epic #1012, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
new-csr, make-p12, set-secrets and selftest. PowerShell 7 only, openssl from Git for Windows (mingw64, MSYS argument rewriting disabled). Keys live outside the repo and are never overwritten; the certificate must match the key; the .p12 is packed in the legacy format and read back; secrets are piped on stdin, never printed or put on a command line. The macOS proof job now runs the script's own selftest and imports its output.
Tested locally: selftest passes incl. refusal of a mismatched certificate; legacy format confirmed; CSR subject correct; new-csr refuses to overwrite a key; set-secrets stops before gh on a missing file.
Epic #1012, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Throwaway self-signed certificate generated on the runner each run; no key in the repo. Legacy and OpenSSL 3 default .p12 both packed; legacy must import with key paired, a wrong password must be refused, and the default's result is recorded as a notice.
Epic #1012, Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes the Environment rule that would refuse the first signed run, adds IPA signature and altool validation checks, aligns the privacy goal with the CoreScope choice, and marks rounds 1 to 3 done. Verdicts are on #958.
Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finding 6 and owner round 2 now use app.offband.meshcore.ios, after Apple refused the original ID.
Feature #200.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four Epics: Windows signing toolchain proven on a Mac runner, signed IPA and TestFlight lane, App Review package, Integration Testing. Every finding cites the research on #958.
Agent: ChartreuseBay (session 425e424d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Android 11 and below the BLE plugin refuses to scan while system Location is off. The Scan button caught that error and only logged it, so the user saw nothing; one tapped Scan 172 times. A failed scan now shows a persistent message: a specific one when Location is off, a plain generic one for anything else, never the raw exception. The detail still goes to the log, and Scan stays pressable.
New owner-approved strings scanner_scanFailedLocationOff and scanner_scanFailedGeneric; other locales fall back to English via untranslated.json.
Tests: each message present on its failure, absent on success, still shown after 10 s, and Scan retries after a failure. Each check was made to fail first against a broken build (persist false; log-only; misclassification).
Plan by EmeraldBrook on #1033.
Agent: BrightDog (session 38fb0e08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Route into the app on return from settings only when the scanner is the current route again, the same condition the connection listener uses. A popping route is outside the navigator's present set, so the settings route does not block it.
The share tests now wait frame by frame on their own condition instead of a fixed 2-second window, and the persistence checks were re-proven to fail with persist: false.
Correct a stale comment: the Experimental unlock is on the Device Info Build row, not About.
Agent: BrightDog (session 38fb0e08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A radio that will not connect is exactly when a user needs the log, and
the only way to it ran through device Settings, which needs a connected
radio. The scanner now has a settings icon that opens an app-only
settings screen: App Settings, Debug (Share logs, the logging switch,
both log viewers) and About.
Navigation only. How logs are written is untouched: Share logs calls the
same LogExport.shareLogs the log screens use, on the same on-disk file.
The device Settings screen keeps every category. Its About pane and the
app-level Debug rows moved into AboutView and DebugLogTiles, which both
screens now share; Device CLI, Serial capture and Mesh topology need the
radio and stay in device Settings only.
Export failures now persist until dismissed and are logged: "No log
available to export" was a 4-second snackbar, and a share or save that
threw had no handler at all. New owner-approved string:
debugLog_exportFailed. Other locales fall back to English per
untranslated.json.
Tests pin both sides with the radio disconnected: Share logs hands the
on-disk file to the share sheet, About shows the version, no device
category or radio row appears, both error messages persist, and device
Settings still has everything. Each check was made to fail against a
deliberately broken build first.
Part of Epic #693.
Agent: BrightDog (session 38fb0e08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v1.7.0 release run failed in release-signed.yml's linux job with the #929 glibc header clash (once_flag / pthread_once) on the ubuntu-26.04 image. #929 pinned build.yml's linux job to ubuntu-24.04 but never touched release-signed.yml, whose linux job still ran on ubuntu-latest. The release job needs all three platforms, so no GitHub release could publish.
Child of release tracker #1005.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bump pubspec to 1.7.0+68 and add the four release-gate docs (CHANGELOG [1.7.0], GitHub release notes, Play what's-new at 403/500, Discord post). Content: per-message storage with the one-way schema 1 to 2 upgrade, radio-profile isolation, sync-is-truth contacts, custom contact names, region scoping, the Settings restructure, CLI for stock 1.18 companions, and the bounded BLE reconnect.
The release notes, Play text, and Discord post carry the #953 disclosure: earlier versions cannot open the converted database. Firmware caveats stated per feature: regions need companion 1.16+ or current Offband firmware (verified in offband-v1.5.0-beta7 and v1.6.0-beta1); CLI needs stock 1.18; the 0xC5 badge settings stay hidden until Offband firmware ships them (f3d30a88 is in no firmware tag yet).
Build task under release tracker #1005. Part of epic #312.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The companion "CLI console" entry was gated only on the target being a
companion. A pre-1.18 radio, including Offband before the absorb, refuses a
type-3 send: companion-v1.17.1 CMD_SEND_TXT_MSG accepts only plain and
CLI_DATA and answers anything else with ERR_CODE_UNSUPPORTED_CMD
(companion-v1.18.0 adds CLI_COMMAND). The connector only logs error
frames, so the command waited out its mesh timeout and the console showed
the #891 row, which blames the target for a limit of the user's own radio.
- Gate: DeviceCliScreen.offersRemoteConsole requires a companion target
AND a connected radio that speaks the 1.18 CLI (allowsCliFrame, the same
check that gates the local Device CLI; type-3 send and frame 66 shipped
together in 1.18). Disconnected or unclassified offers nothing. The chat
menu uses it.
- Fail fast: RemoteCliTransport fails the command at once with
CliConsoleUnsupported when the radio refuses the send. The window arms
only when the text frame is written (the onAttempt hook, after the path
update) and closes on the command's SENT, which on 1.18 carries an
expected-ack of 0. So an unrelated unknown-command refusal before the
write, or a DM's SENT, cannot be pinned on this command. Dispose
releases a command still waiting.
- Console: a refused send shows "<command>: not sent. Your radio's
firmware doesn't support remote CLI." (owner-approved), not the timeout
row.
Tests: the gate (positive and negative across populations, roles, and
disconnected); fail-fast on the refusal, ignoring other codes, errors
after the radio accepted the send, a DM's SENT, an error before the write,
and dispose; the console row.
Reviewed by a one-off Sonnet adversarial pass (Gemini budget down). Its
Medium finding, that the window armed at run() before the write, is fixed
here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A companion that has not granted us remote CLI queues our command as a
plain message and never answers, so from the console's side the silence
is as likely a permission answer as a lost packet. The console showed the
same bare "no reply in N s" row it uses for the local device, which reads
as "the radio is down".
The remote console now shows: "<command>: no reply in N s. This radio may
not allow remote CLI from you, or is unreachable. Not retried." The local
console keeps its plain frame-timeout row. The send itself was already a
single attempt (#999), so "Not retried" states what happened.
- device_cli_screen: the timeout row picks the remote string when the
console targets a contact. A transportOverride test seam lets a test
drive a deterministic timeout; the screen owns and disposes it.
- l10n: deviceCli_remoteNoReply (owner-approved wording).
Tests: the remote timeout renders the full interpolated string; the local
timeout renders its exact string and never the remote permission hint.
Reviewed by a one-off Sonnet adversarial pass; its test gaps are fixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extend the one CLI console (device_cli_screen, F2 #883/#884) to target a
permitted companion over the mesh (TXT type 3, #888/#889), reusing the
console rather than adding a screen or a chat-borne command path. Opened
from a "CLI console" action on a companion contact; command and reply
both live in the console.
- cli_console_transport.dart: CliConsoleTransport with a local and a
remote implementation, and one CliConsoleTimeout the console catches.
The remote transport sends with retries: 1 (no auto-retry - an
unpermitted companion queues the command and never replies, so a retry
only spams the mesh) and correlates the reply via the receivedFrames
listener, gated to CLI_DATA so a chat message or inbound command from
the same contact can't complete it (Sonnet review).
- device_cli_screen: optional remoteContact; "Remote CLI" + contact name.
- chat_screen: companion-only "CLI console" entry (Contact.isCompanion).
Also corrects #890: CLI and chat are separate surfaces. The receive
pipeline filed every contact frame into the conversation regardless of
TXT type, so a companion's CLI reply (type 1) and an inbound command
(type 3) leaked into the chat thread. A guard now drops CLI-typed
messages from the message pipeline - the console is their only surface -
and #890's in-chat command bubble is removed.
Review (Sonnet, one-off, Gemini budget down): redact secret reply text
in the remote path's late/unmatched log, matching LocalCliService;
dispose cancels the subscription; document that remote truncation is not
detectable post-reassembly.
Tests: transport round-trip / sender-filter / timeout / no-retry /
CLI_DATA gate; Contact.isCompanion; remote console title; and the
pipeline keeping CLI (type 1 and 3) out of the conversation while a plain
message still enters.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Upstream 1.18 lets a permitted contact send a remote-CLI command (TXT
type 3) to run on our radio. Before this, _parseContactMessage only knew
plain (0) and CLI_DATA (1), so a type-3 frame was warn-dropped as an
unknown type and never surfaced.
- meshcore_connector: accept txtTypeCliCommand in _parseContactMessage,
skip SMAZ decode for it (like CLI_DATA), and flag the Message
isCliCommand. Add @visibleForTesting parseContactMessageForTest.
- message model: new isCliCommand field threaded through the
constructor and copyWith.
- message_store: persist and read isCliCommand; skip SMAZ on read for it.
- chat_screen: label an inbound command bubble "Command for your radio"
and exclude it from the incoming-translate action.
- l10n: chat_remoteCliCommand.
test/connector/inbound_cli_command_test.dart: type-3 accepted+flagged,
type-1 isCli-not-command, plain-neither.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F3/E3.1 target split. The command service now picks the message type by
the target contact's role: a companion (chat) gets TXT_TYPE_CLI_COMMAND
(type 3), a repeater/room server keeps the universal CLI_DATA (type 1)
it has always used. One line in the send path; the reply handling,
correlation and timeout model (which already carries the 600 ms
companion reply hold) are unchanged and serve both.
Companion replies come back as CLI_DATA (verified: MyMesh.cpp companion),
so the existing contact-agnostic reply listener routes them to
handleResponse unchanged; a UI entry point to open the CLI console for a
companion is the remaining downstream piece.
Tests: a real send proves companion -> type 3 and repeater -> type 1,
driven deterministically off the send frame. Full suite green (1432);
Gemini review: test race fixed, the reply-path concern refuted against
the source-verified CLI_DATA reply.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F3/E3.1. When sending a CLI command to a remote peer, the message type
is chosen by the contact's ROLE (owner ruling): a companion (chat) gets
the upstream-1.18 command type (3); a repeater or room server keeps the
universal CLI_DATA type (1) it has always used. Decided by role, not
firmware version: a Contact carries no version, the user types the
commands (passthrough), and a non-1.18 companion silently drops the
command type (surfaced as no reply, #891).
Adds txtTypeCliCommand (3) and cliTxtTypeForContactType(), and
parameterizes buildSendCliCommandFrame with the txt type (default
CLI_DATA, so the existing repeater path stays byte-identical). A later
refinement could pin a peer's version via a `ver` probe on connect.
Tests: the role mapping and the frame's type byte + command passthrough.
Full suite green (1430); Gemini 2.5-pro review: one naming nit taken.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F3/E3.1. Upstream 1.18 gates remote CLI (TXT type 3) on contact flag
0x10 (isRemoteCLIAllowed): the receiving radio checks it, so setting it
on a contact grants that contact permission to run CLI on this radio.
Adds the constant, Contact.remoteCliAllowed, a remoteCli param to
setContactFlags (same set/clear pattern as the telemetry flags, reading
a fresh device snapshot first), and an opt-in SwitchListTile in the
shared contact settings dialog (default off). Written with the existing
add/update-contact frame; the flags byte already rides it.
Tests: the 0x10 bit on the write-path frame (set/clear/coexist) and the
getter. Full suite green (1424); Gemini 2.5-pro review: no defects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner test 2026-10-26: Stop, then a BLE connect to another radio 6 s
later. The Stop disconnect was still releasing the T1000-E; every
connect path only refused connecting and connected, so the new connect
started during disconnecting. The disconnect's tail then cleared the
new link's device, name and state: the status stuck, and the frame
size stayed at 20 bytes with MTU 176, which left the channel composer
unusable.
- disconnect() tracks the one in progress; a connect (BLE, USB, TCP)
waits for it, and a second disconnect joins it. A manual caller
joining an automatic disconnect still suppresses the retry.
- A superseded attempt logs its own radio's name, not whatever name the
newer connect has written by then.
Watched failing first: the TCP connect started before "Disconnect
complete". Full suite 1720 pass, analyze clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On 2026-10-25 the client retried an unreachable T1000-E about every
14 s for 7.7 hours, and no other connect could start meanwhile (#995).
- connect() reset the attempt counter at the start of every automatic
attempt, so the backoff never grew and nothing gave up. A
ReconnectPolicy now owns the count: 5 attempts or 3 minutes, and only
a user connect, a manual disconnect or a successful link resets it.
- A connect the user starts, BLE, USB or TCP, abandons an automatic
attempt in flight instead of being refused. A generation check after
each await in the BLE connect stops an abandoned attempt before it
writes shared state.
- Refusals are logged and shown, never silent (the USB screen dropped
taps without a trace).
- The scanner status bar shows the attempt with a Stop button, and
"Couldn't reach" with Retry once the retry gives up.
Tests watched failing first: the attempt count (1, expected 2), the
give-up, the TCP refusal, the silent BLE refusal, and the three screen
cases. Full suite 1719 pass, analyze clean.
Epic #996, design on #995.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three defects turned a 13-hop 3-byte-hash message into a 39-hop badge:
- log-RX ingress stored the width-stripped hop count; now stores the raw packed path-len byte, the same encoding fromFrame keeps
- the repeat merge substituted the path BYTE length into the packed field; mergePathLenRaw now compares decoded hop counts and never sees a byte length
- fromFrame read the V3 path_len field signed, so 3-byte-width values (>= 0x80) collapsed into the flood sentinel; now unsigned with 0xFF kept as the direct marker
Rendering now groups path hashes by the packet's own declared width (ChannelMessage.effectivePathHashWidth), with the radio's device-wide mode only as the legacy fallback, so the path screen, chat via-line and route-reply all agree with the badge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_customNames was only ever added to. Rename contact X on radio A, switch
to radio B which also holds X, and B showed A's label, breaking the
owner's decision that custom names are per radio.
- The hydrate clears the map before loading the connected radio's names.
- disconnect() clears it with the contact list, so no label outlives
the link it belongs to (Gemini finding).
T37 (switch) and T37b (disconnect) both failed with the previous radio's
"Chuck" before the fix and pass after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>