Review findings on the logging commit:
- The notify decision line said "live list" or "cached list" by
repeating the lookup. The lookup now reports which list answered,
so the line cannot drift from it.
- Reading the app version at startup can throw. It now logs the error
and keeps "unknown" instead of failing startup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The exported log could not show what the client did when a channel's
phone notify mode was set, or why a channel message notified, so #1121
could not be diagnosed from a log.
- Every notify-mode save logs radio, slot, channel, a one-way PSK
fingerprint, old mode with its source, new mode, and the read-back.
A save refused for a missing radio key or PSK logs as an error.
- Any change to the notify table, by any path, logs before and after.
Settings that fail to parse at startup, or fail to write, log errors.
- The notify dialog and the channel action sheet log what they show.
- Every incoming channel message logs its notify decision: which
channel the slot held, from the live or cached list, the mode and
where it came from, and posted or suppressed with the reason.
- NotificationService queue, batch, suppress and failure paths go to
the app log instead of debugPrint.
- The app version is logged at startup and on every connect.
- appLogger no longer drops messages when the in-app log view is off;
the log file always receives them, as connector lines already did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gemini review of #1117, findings verified against the code and firmware.
Kept:
- planChannelRestore was tested only through the connector. There are now
11 direct tests: Public to slot 0, nothing else there, a live channel
never overwritten, a channel already on the radio left out, erased slots
cleared first, no slot used twice, no key written twice, erased and empty
cache entries never written, and no-room channels reported.
- Removed a Public-first sort that guarded nothing: nothing but Public is
ever placed in slot 0, so the order cannot matter (a mutation dropping
it passed every test, which is how it showed).
Justified, not changed:
- "A second add right after Public displaces slot 0 uses a stale list":
the re-read starts synchronously in the write's finally, before the write
returns, so the table stays busy and the add gets the busy message.
- "Public is refused on new firmware when slot 0 is occupied": unreachable
there. Public can't be deleted, and boot puts a missing Public into the
first empty slot. So Public missing with slot 0 occupied happens only on a
full table, where nothing has room. On older firmware the fallback keeps
slot 0's channel from being overwritten.
- "Restoring Public over an erased slot 0 overwrites a recoverable record":
erased flash holds nothing. A cached channel from slot 0 is re-homed
elsewhere, not lost.
Full suite 2045 pass, analyze clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Restore to radio (#1060) replayed each cached channel into its remembered
slot, in place, and reported how many frames it sent. On the firmware
shipping next that is refused for every erased slot (an erased record
reads as occupied, #1865), for slot 0 unless it is Public, and for Public
anywhere else (#1896). It would still have said it restored them.
planChannelRestore (lib/helpers/channel_restore_plan.dart) now places by
identity:
- a cached channel whose key is already live on the radio is left out
- Public goes to slot 0
- any other channel goes to its remembered slot when that slot is free or
erased and not slot 0, otherwise to the lowest free slot
- a live channel is never a destination, so nothing is overwritten
- an erased slot is cleared before it is written
Every write goes through _writeChannelSlot and waits for the radio's OK or
ERR. The result is what the radio confirmed. The screen says "The radio
confirmed all N channel(s)", or "confirmed X of Y. Not restored: ...",
persistently. Waiting for each answer also paces the writes by the radio
itself, which replaces the fixed 10 ms gap. The comment claiming a confirmed
count was impossible is gone. The confirm dialog lists only the channels the
radio is missing, and no longer warns that it replaces what the slots hold,
because it doesn't.
The #1060 restore tests are rewritten to the new contract, keeping what they
protected (no erased entry written back, nothing cached is refused, the key
travels with the channel, writes are paced), and adding: slot 0 for Public
only, clear-before-write, nothing live rewritten or overwritten, refusals
reported by name.
Mutation-checked with this commit and the previous one: not skipping slot
0 fails 5 tests; never clearing first fails 1; counting sends as confirmed
fails 1; allowing a Public delete fails 2; a one-# hashtag rule fails 1.
Full suite 2034 pass, analyze clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The firmware shipping next (firmware-base, #1896) refuses any channel but
Public in slot 0, even while it is empty, refuses Public in any other slot,
and refuses clearing Public. Every refusal is ERR_CODE_BAD_STATE, the same
code as an occupied slot, so the client has to avoid them, not interpret
them. Owner rulings, 2026-10-28: slot 0 "should always remain public",
"make sure Public can't be deleted", "Of course add the real public".
- decideChannelSlot takes a required isPublic. Public goes to slot 0 when
it is free; nothing else ever does. The fallback that lets Public take
another free slot is reached only when slot 0 holds some other channel,
which happens only on older firmware.
- The shared allocator takes the key being written and decides from it,
so Join Public, a typed private key, a QR share and a hashtag join all
follow the rule, and a second Public is refused on every one of them.
- planChannelImport follows the same rule for a stock config file.
- Deleting Public is refused at the attempt with "Public can't be removed
from the radio. It always stays in slot 0." The control stays live.
- Edit Channel locks Public's key, and refuses typing Public's key into
another channel while Public is on the radio.
- A hashtag join of "public" (leading # and spaces and trailing spaces
dropped, any case: the device's namesPublic rule) adds the real Public,
not sha256("#public").
The #1022 test that asserted Public has no claim on slot 0 is replaced by
tests for the rule above.
On older firmware, slot 0 is no longer handed to other channels, so one
slot of 40 goes unused there. That follows the owner's ruling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second Gemini round on Epic #1021, findings verified against the code.
Kept, fixed:
- A writer's re-read called _syncChannels directly, so with two writes
overlapping, the first to finish started a sync while the second still
waited on the same reply queue. The trigger was restoreChannelsToRadio,
the one write path the UI did not gate. Writers now only request the
re-read; the last write to finish runs it, once. Nested writes (an edit's
clear and add) and batches re-read once, at the end.
- Restore to radio refuses with the busy message while the table is busy,
like every other channel write. With that, no write path can overlap a
sync.
Justified, not changed:
- "A write slips in between a batch ending and its deferred sync
starting": calling an async function runs synchronously up to its first
await, and _syncChannels sets _isSyncingChannels before any await, so no
tap can land in between.
- "A disconnect leaves the write counter stuck": every write decrements in
finally, and _writeChannelSlot returns within the 5 s reply timeout even
after the queue is cleared. A sync deferred meanwhile runs when that write
ends.
- "A deferred sync is dropped if a sync is already running": reachable only
when a write overlaps a running sync, which no write path allows now.
Mutation-checked: a writer starting its own re-read fails the new
overlapping-writes test. Full suite 1963 pass, analyze clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gemini review findings on Epic #1021, after verifying each against the
code.
Kept, fixed:
- A sync started while a channel write waits for its reply shares the
ordered reply queue with it, so the sync's empty-slot ERR could be taken
as the write's answer. The trigger is real even though the review named a
path that doesn't exist: pull-to-refresh, the sync button, the 400 ms
channel-changed debounce, and dev's 3 s failed-sync retry all call
getChannels at any time. getChannels now defers while a write is in
flight, and the last write to finish runs it once. The writers' own
re-reads call _syncChannels directly.
- Config import and community delete hold the guard across their whole
loop (batchChannelWrites), so a deferred sync cannot start between two
writes.
- A key change whose clear lands but whose add fails used to report "did
not confirm the change", understating that the channel is now gone. It
returns ChannelWrite.clearedNotAdded, with a message saying the slot is
empty and to add the channel again.
Justified, not changed:
- A reply arriving after the 5 s timeout, once the waiter has been
dropped, shifts the queue by one. Keeping the waiter instead would shift
it permanently whenever a reply is truly lost. Firmware answers
CMD_SET_CHANNEL synchronously, far inside 5 s, and a disconnect clears the
queue, so dropping is the smaller risk.
- Claimed crash on removeAt(0) with an empty queue: both handlers check
isEmpty first.
Mutation-checked: removing the deferral fails 2 tests; returning the add's
own result fails 1. Full suite 1962 pass, analyze clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebasing Epic #1021 onto dev met #1063, which keys channel history by the
channel's PSK. On a slot reassignment it keeps the old channel's history,
so re-adding that channel brings it back, and only a delete removes history
outright. #1026's write path had cleared history on every change; the
rebase resolution moves it onto dev's rules. A confirmed reassignment
(an add, or an edit to a new key) purges only the slot's legacy keys and
the in-memory entry. A confirmed delete removes history outright. All three
still happen only after the radio's OK.
Nothing in dev's suite caught a reassignment true-deleting the old
channel's history: with that mutation applied, all tests passed. A new test
(channel_write_history_identity_test.dart) drives the real PSK-keyed store
and fails 2 of 4 under it.
The Edit Channel dialog now checks for a busy channel table before #1103's
identity check. Mid-sync the list is empty, so the identity check closed the
dialog and blamed a changed channel. The busy check keeps the dialog and the
user's edits open and gives the real reason.
#1060's restoreChannelsToRadio now counts as a channel write in flight
while it runs, so an add made during a restore is refused instead of
choosing a slot the restore is about to fill (in the rebased #1026 commit).
Full suite 1954 + 4 pass, analyze clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part A of the #1021 test child.
Widget tests (test/helpers/channel_slot_ui_test.dart) for all six refusal
messages: busy, all slots full (with the count), Public already on the
radio, and the three unconfirmed-write outcomes (occupied, rejected, no
reply). Each one shows on its own failure, is still on screen 10 seconds
later (past the 4 second toast default), and none shows when the action is
allowed.
Connector test: a channel sync that fails restores the cached list with
hasLoadedChannels false, and the table still reads as not busy, so an add
gets a free slot. This is the lockout a hasLoadedChannels gate would have
caused on a flaky link, driven through the real sync rather than asserted.
The "Public is already on this radio" check moves from the channels screen
into channel_slot_ui.dart, so all six messages live in one tested place.
Mutation-checked: dropping persist: true fails all 7 persistence tests;
gating the table on hasLoadedChannels fails the failed-sync test. Full suite
1805 pass, analyze clean.
Part B (on-device) is the owner's hardware pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setChannel sent CMD_SET_CHANNEL and moved on without the radio's OK or
ERR, then cleared the slot's message history on the strength of having
sent it. Firmware with OffbandMesh/meshcore-firmware#1864 refuses a
different channel over an occupied slot with ERR_CODE_BAD_STATE and keeps
that channel intact, so the client would have deleted the history of a
channel the radio still holds.
Connector:
- setChannel, deleteChannel and the new editChannel wait for the reply,
using the existing generic-ack queue (each pending entry can now carry a
completer), and return ChannelWrite: ok, occupied (BAD_STATE), rejected,
or noReply (timeout, send failure, or not connected).
- History is cleared only after an OK. A refusal or no reply keeps it.
- A timed-out waiter leaves the queue, so a late or lost reply cannot be
matched to the next command.
- isChannelTableBusy covers an unanswered write as well as a sync. Without
that, a second add made while the first is waiting would pick the same
slot, because the list does not show the first one yet.
- editChannel does clear-then-add for a new key. #1864 firmware refuses a
new key written in place (owner ruling on that epic: changing a key is
clear-then-add), so the edit dialog would have broken once it shipped.
A rename is still a single write.
Callers:
- Every add, edit and delete path waits for the result and shows its
success toast only after an OK; otherwise a persistent message says what
happened. The two places the channels screen cleared history itself now
do it only after an OK.
- Edit and delete refuse, with the busy message, while the table is busy.
- Config import and community delete write one channel at a time and
re-read the list once at the end. A re-read after each write would put
the next write inside a sync, where its reply cannot be told from the
sync's. Import counts only confirmed channels; refused ones are listed
as skipped.
- Creating a community: the dialog closes before the write, so the wait
cannot invite a second tap, and a refused channel's message is no longer
replaced by the "created" toast (a #1022 regression, since a newer toast
replaces the visible one).
Tests: 12 connector tests driving synthetic OK / BAD_STATE / NOT_FOUND /
no reply. Mutation-checked: clearing before the reply fails 3, a new key
written in place fails 1, and a timed-out waiter left queued fails 1. Full
suite 1793 pass, analyze clean.
The refusal path cannot be exercised on a real radio until #1864 ships;
the OK path can be tested on hardware now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The channels screen was fixed in #1022, but two more surfaces chose a
channel slot on their own and had the same race.
Community QR scanner:
- A channel share link picked its slot before the confirm dialog and wrote
it after, so a sync during the confirmation (setChannel itself starts
one) could leave it pointing at an occupied slot. The slot is now chosen
after the user confirms, and a refusal keeps the screen open with the
reason showing.
- Joining a community skipped the public channel silently when the radio
was full and still showed the green "joined" toast. A refusal now
explains itself, and the green toast shows only when the channel was
actually written.
- Its private slot finder is deleted.
Config import:
- planChannelImport takes a required isSyncing. Mid-sync the connector's
channel list is empty, so the planner would have written every incoming
channel from slot 0 up, over Public. It now plans nothing and reports
each channel as skipped with a new channelsSyncing reason. The parameter
is required so a caller cannot forget it and get the unsafe behavior by
default.
Both screens now share allocateChannelSlotOrExplain
(lib/helpers/channel_slot_ui.dart), so the "which slot is free" question
has a single answer. channels_noFreeSlot is retired; its only caller moved
to the shared message.
Tests: two planner tests for the syncing case, mutation-checked (with the
guard disabled both fail). Full suite 1781 pass, analyze clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adding #qcchams and #qcchhv from the phone lost Public, from the client and the radio. Three faults combined, all client-side; the firmware's CMD_SET_CHANNEL is a plain indexed write by design and did what it was told.
1. The slot was chosen when the add dialog opened (_showAddChannelDialog computed nextIndex before showDialog and closed over it), so it was stale by the time Add was pressed. 2. The connector empties its channel list for the duration of a sync and refills it slot by slot, so a dialog opened mid-sync saw every slot as free. 3. _findNextAvailableIndex answered 'no free slot' with return 0, which is where Public normally lives, turning a full radio into a silent overwrite of Public.
The decision moves into a pure helper, decideChannelSlot, called at the moment of each write and never earlier. It refuses while isSyncingChannels is true and refuses when full, returning a named reason rather than slot 0. Deliberately isSyncingChannels and not hasLoadedChannels: the latter stays false after a FAILED sync while the cached list is restored into use, so gating on it would disable adding a channel permanently on a flaky link.
Add Public no longer hardcodes slot 0, which it wrote with no occupancy check. Public is identified by its PSK, not its index, so it takes a free slot like any other channel, and is refused if it is already present.
Owner ruling: block the unsafe write but keep the control live, since a greyed-out button is itself a silent failure. Every refusal explains itself and passes persist: true, because showDismissibleSnackBar defaults to a 4 second toast that SAFELANE 6 rules out for an error. A refused add leaves the dialog open so the user can retry.
Mutation-tested rather than trusted: restoring return 0 fails 2 tests, and removing the sync guard fails 4, including the exact reported scenario. Full suite 1779 pass. New strings are English-only with the regenerated untranslated.json, matching the convention of every recent string-adding commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lands the #523 indicator controls, separated from the factory reset (#1057)
that happened to share their branch. #1057 is destructive, untested and the
owner did not ask for it, so it stays behind rather than riding along.
Rotation is 0xC5 0x1A GET / 0x1B SET, gated on TAG_DISPLAY_ROTATION in the
0x1C advert, with no caps-bit fallback and no probing. It is NOT 0x18/0x19:
those are per-channel channel notify, and an earlier build that put rotation
there corrupted silently, because the firmware's 0x19 handler validates a
channel-notify mode and nothing else. The hazard commit 89f1768e is not in
this history at all; this is the post-fix state only.
Display mode and the status LED read device-info bytes 86 and 85. Nothing
reads byte 87, which contract section 2 retired permanently.
Provenance, since the route matters: cherry-picked ea09d2f3 with -m 1. That
commit is the merge of the #523 work into the #1095 advert work, and the
rotation fix lives in its merge resolution rather than in a commit of its own,
so its first-parent diff is exactly what #523 adds on top of what dev already
carries. The result is 956 insertions across 28 files, matching ea09d2f3's own
stat, and offband_indicators.dart, indicators_view.dart and
indicators_connector_test.dart are byte-identical to the old branch tip.
Adversarial review (gemini-2.5-pro) found a crash, fixed here: the rotation
control force-unwrapped displayRotation, but the advert tag that ENABLES the
control arrives before the rotation GET reply that supplies the value, so the
pane threw on any slow link. It now takes an empty selection, matching the
display-mode control beside it, which was already null-safe. Proved by probe:
restoring the unwrap yields "Null check operator used on a null value" and
turns 2 of the new widget tests red.
The review's second finding, a read reply overwriting a newer confirmed write,
is real but deliberately NOT fixed here. The obvious guard is to drop read
replies while a write is outstanding, and _sendIndicatorSet sets that flag
with no timeout and no cleanup, unlike _awaitDeviceUi which clears it in a
finally. That guard would wedge rotation reads for the rest of the session
after any SET that went unanswered, which is worse than the stale value it
prevents. Giving _sendIndicatorSet the same timeout discipline is the real
fix and is follow-up work, not something to change minutes before a cut.
flutter analyze clean, 1945 tests pass and 1 skipped, including 25 indicators
connector tests and 4 new widget tests.
Not hardware-tested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI has built macOS on every run since the matrix landed and thrown the
result away, while a macOS user runs the iOS TestFlight build instead
(#1105). build.yml now zips the .app (ditto, symlink-safe) and uploads
it like the other platforms, gaining the dart-defines parity the macos
job alone lacked; release-signed.yml gains an unsigned macos job whose
offband-macos.zip becomes a blocking release asset. Signing and
notarization follow in #1109.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrapping the menu in a scroll view re-indented this line, so dart format
split the if statement across two lines and CI's --fatal-infos analyze
flagged curly_braces_in_flow_control_structures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings feat/1014-ios-app-review up to date with dev for PR #1049.
Conflicts: the app name in TESTFLIGHT_GUIDE.md and tools/ios-signing/README.md
keeps "Offband MeshCore" (#1050); channels_screen.dart takes dev's channel
menu (new radio sound tile) and re-applies the #1075 scroll wrapper.
testflight.yml is dispatch-only again, as the push trigger was only for the
owner's TestFlight pass (#1047). REVIEW-NOTES encryption line and PLAN
finding 14 updated to match what App Store Connect shows.
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>
User report (Jeremy AA4KY, iOS; one codebase so the fix is
platform-wide): the Edit Channel dialog was type-blind, offering a
free-text PSK field and a random-PSK die for every channel. A hashtag
channel's key IS its name, so hand-editing it silently diverges the
channel from everyone else on that hashtag.
- hashtag: key read-only and copyable (copy icon replaces the die),
derived live from the name while renaming, exactly as adding that
hashtag would. Save refuses a name that stops being a hashtag or
carries a double '#' (a '##name' hashes differently), inline error.
- community hashtag: name and key both read-only (community-secret
derived), key copyable.
- private/public/communityPublic: unchanged.
- Save also refuses, with a persistent error, when the slot's live
occupant no longer matches the channel the dialog opened on, so a
background sync replacing the slot mid-edit (the 1060 class) cannot
be overwritten by a stale dialog (review finding).
Policy helpers are unit-tested; with both guards behaviorally
disabled 2 tests fail. Suite 1903 passed / 1 skipped. English strings
only; untranslated.json regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review (gemini-2.5-pro) on the push candidate raised five
findings. Two were real and are fixed here; the other three are justified
in the PR body.
Storage is an untrusted source too. The guards already on this branch stop
an erased (all-0xFF) record being adopted from the WIRE, but a build from
before they existed could have persisted one, and loadCachedChannels
applied no filter. _cachedChannels is a fallback identity source for
_findChannelByIndex and is re-saved by the unread paths, so such a record
comes back as the channel for live traffic and outlives every radio. It is
dropped at the load boundary, which protects every consumer at once. It
carries nothing recoverable: the name is U+FFFD and the PSK is all-ones.
restoreChannelsToRadio sent its writes in an unpaced loop. On BLE sendFrame
prefers write-without-response, so its await returns when the frame reaches
the OS and not when the radio has taken it: a multi-channel restore could
overrun the radio and lose channels with no error raised anywhere. The USB
branch of sendFrame already spaces its own frames for exactly this reason.
Same 10ms gap between consecutive channel writes.
The restore result is documented as written and NOT confirmed. Counting
confirmations after the re-sync was tried and reverted: getChannels returns
once the first request is away rather than once the sync has finished, so
it would have read a list the radio had not answered for and reported zero
every time. The erased-slot banner is re-derived on every sync and stays up
if writes did not land, so it remains the authoritative signal.
Both new guards proved against planted defects rather than assumed:
removing the load filter turns 2 tests red, and removing the pacing yields
"Expected: a value greater than or equal to <20>, Actual: <0>".
flutter analyze clean, 1896 tests pass, 1 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from the owner's field report: a Channels screen full of
replacement characters, and a channel he had silenced chirping again.
An erased record is not an empty one. Erased flash reads back 0xFF and
Channel.isEmpty tests for 0x00, so a slot the radio had LOST was adopted
as a channel, rendered with U+FFFD, and then persisted over the client's
own good cached list. One missing validation gate, two symptoms.
- Channel.isErased / isUnusable: an all-0xFF record, or a name that did
not decode, is never adopted.
- A sync that sees an erased slot KEEPS the cached list, reusing the
fail-closed branch #1062 already had for unconfirmed holes.
- restoreChannelsToRadio() writes the cache back to the original slots
with their PSKs, behind a confirmed banner action.
Radio notify was keyed by slot, not identity. #544 landed after #1061 and
keyed its in-memory map by slot index; the slot-identity handler cleared
the slot's messages but not its notify mode, so a channel that moved
slots handed its mode to the new occupant and lost its own.
- _channelNotifyModes is keyed by PSK hex, matching channelNotifyModesByRadio
(#848) and the PSK-keyed history (#194).
- A reply whose slot does not resolve to a confirmed channel is dropped.
- setChannelNotify takes a Channel and resolves its CURRENT slot at send
time, so a stale handle cannot address the wrong one; unresolvable is a
visible refusal, not a silent write.
Both guards proved against planted defects: forcing isErased false turns
5 tests red including the cache overwrite, and restoring the slot-keyed
map yields Expected: null, Actual: RadioChannelNotify.off.
flutter analyze clean, 1885 tests pass. New strings English-only with
untranslated.json regenerated.
Task: meshcore-open-75d
Agent: CalmBay
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stock-parity gap: the companion radio persists a pin set over
CMD_SET_DEVICE_PIN (37) and reports the configured value in device
info bytes 4..7, but the client exposed neither.
- cmdSetDevicePin + buildSetDevicePinFrame ([cmd][pin x4 LE]).
- parseDevicePin reads the device-info field; deviceBlePin getter
(0 = firmware build default, null until reported), nulled on each
new connection handshake; setDevicePin validates with firmware's
own rule (0 or 100000-999999) and sends like the other config
writes.
- Settings tile in the device Actions card, pin shown in PLAIN text
(owner ruling 2026-11-06: no masking, parity with other mesh
clients). Edit dialog with inline validation and Reset to default;
persistent re-pair notice on success, persistent error when the
send fails. English strings only; untranslated.json regenerated.
Gemini gate on the diff: fixed the unsaveable prefill of CLI-set
out-of-range pins, the failed-send success lie, and a controller
leak; double-tap idempotence and the short-frame fallback justified
in the PR. Negative evidence: mutating the encoding, the parse
offset, or the validation fails 4 of the 7 new tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gemini gate (gemini-2.5-pro) on the pre-push stack, four findings:
- Held channel message frames are now keyed per radio: verifying the
unbounded-growth finding exposed that frames held from radio X would
have flushed into radio Y's conversations after a radio switch. A
parked radio's frames flush when that radio next completes a sync. No
cap is added: held frames are popped messages, dropping one is
guaranteed loss, and growth is traffic-bounded (<=172 bytes/frame).
- _loadChannelMessages merges messages that arrived during the disk
read instead of clobbering the list, so the slot-identity reload
cannot hide a message the held-frame flush just filed (disk was never
at risk: saves merge by identity, #343).
- sendChannelMessage refuses the write for a slot this connection has
not confirmed (the persisted copy would fall back to the legacy
slot-index key and be orphaned), and the chat screen surfaces a
persistent error at every send path instead of greying anything.
New English UI string; untranslated.json regenerated (#1063).
Three integration tests cover the fixes (#1064); with the guards
behaviorally disabled exactly those three fail (+3 -3). Full suite
1807 passed / 1 skipped. OUTCOMES.md records the gate and dispositions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The owner waived the pre-PR hardware gate for feature 1061 (no way to
reproduce on demand); validation moves to live testing after merge.
Merges still require the explicit per-PR grant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plan of record (four approved parts, the Epic B re-scope) and the
outcomes: what each Epic shipped, the slot-level ingress gap the
integration tests exposed, the positive and negative test evidence, and
what stays open until the owner's hardware pass.
Issue: #1064
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Integration tests for Feature #1061 across Epics A (#1062) and B (#1063):
the #1060 bug-validation scenario (wipe + re-import with shuffled slots:
list matches device, no misfiled or mis-rendered message, notify modes
evaluate against the right PSKs), a manual device-side reorder (history
follows the channel identity to its new slot), and a flaky sync
mid-reconnect (held traffic files only after a confirming sync).
Writing them exposed one gap: _flushHeldChannelFrames documented that a
frame for a still-unconfirmed slot re-holds, but the handler only gated
on the list-level barrier, so after a partial sync such a message would
file by raw index against an unknown identity and persist under the
legacy index key. _handleIncomingChannelMessage now re-holds it until a
sync confirms the slot.
Test plumbing: channelNotifyProbeForTest observes the notify decision
(slot, label, PSK, mode) without reaching the NotificationService
singleton; wireMessageStoreForTest wires the message store the way the
SELF_INFO handshake does.
Issue: #1064
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Persisted channel history has been PSK-keyed since #194; the remnants
that could still cross-wire on a slot reassignment were in-memory and
legacy. Epic B of #1061, stacked on #1062:
- _completeChannelSync's identity diff now drops the slot's in-memory
conversation and reloads it from the store, which reads the NEW
identity's own history through the (now fail-closed) resolver.
- setChannel's reassignment clear purges only the LEGACY slot-index
keys (new clearLegacySlotHistory), so the outgoing occupant's
PSK-keyed history survives and comes back if the channel is re-added.
True deletes (deleteChannel) keep the full both-key clear.
- A WRITE that falls back to the slot-index key now logs an error with
the orphaning consequence spelled out; post-#1062 live ingress cannot
reach it.
- The #193-era comment claiming history is slot-keyed is corrected and
the setChannel/resolver interplay documented.
Tests: 2 connector tests (in-memory drop on identity change, kept on
same identity) + 5 store tests (legacy purge, PSK history survival,
no adoption onto the new occupant, true delete).
Issue: #1063
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The advert is requested from the device-info handler. On Android that is
genuinely after MTU negotiation, because requestMtu(185) is awaited before
service discovery and before device-info is asked for; elsewhere the platform
reports the negotiated value through the MTU stream, so the request could in
principle precede Dart observing it. It does not matter in practice, since the
link-layer negotiation has already happened, today's advert fits inside even
the 20-byte floor, and TAG_CONTINUE covers overflow.
Flagged rather than left to be discovered, and checked in the connect path
rather than asserted from the contract.
Also notes that the Windows .exe is only the runner shell, so its timestamp
does not move when Dart changes. The payload is data/app.so, which is what was
verified and re-delivered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Corrects this report. It previously said the rotation hazard could not be
fixed on feat/523-display-indicators because that branch was checked out in
another worktree. That was over-respecting a lock: git refusing to commit to a
branch checked out elsewhere is checkout bookkeeping, not a permission, and
ownership had already been transferred. Removing a worktree frees the ref and
does not delete the branch.
The 523 worktree was verified completely clean before removal, so nothing of
ChartreuseGate's was at risk, and the branch move was confirmed a genuine
fast-forward with merge-base --is-ancestor before relying on it. No history
rewritten.
The sweep that followed found a SECOND carrier I had missed: test/combined-1054-523,
a test-build branch, which is exactly the kind someone flashes from. Finding it
is the argument for sweeping rather than fixing the branch you were told about.
git branch --contains gives the complete carrier set; all three now hold
0x1A/0x1B, and the bad commit was never pushed to any remote.
#1057 factory reset merged in so the build carries every unmerged client
feature and that test branch is fully superseded, verified as a file superset.
Re-checked after that merge that it had not resurrected the pair.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both artifacts come from the combined branch so one install exercises the
advert, channel notify, rotation and all of #523 together.
The APK signature is verified rather than assumed: apksigner reports
CN=Benjamin Wiechel. A worktree without key.properties silently produces a
debug-signed APK, so it was copied in first.
C:\Apps\Offband_MeshCore was deliberately NOT overwritten. The copy there is
from Nov 2 and is what he has been testing; this build is unreviewed and
carries another session's feature merged in, so replacing his working copy
while he slept is not a call to make for him. It went to a sibling folder.
Also names the four things worth a human look, led by the err-5 fallback on a
radio that predates the advert, since that is the one path I could not verify
without his hardware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three additions to the outcomes report, all evidence rather than narrative.
The facts I read from the firmware tree instead of from Agent Mail, since
taking numbers from mail is what caused the collision: the mode encoding
(Unset 0, All 1, Mentions 2, Off 3), the reason the collision was silent
rather than refused ('stored < kChanNotifyCount' and nothing else, so every
rotation value 0 to 3 is a valid notify mode), and the wire numbers.
The FIRMWARE_VER_CODE audit, naming the only two gates that read it with their
lines, both of which are the exception he ratified under #1066 and both
untouched.
And the one instruction I could not satisfy: he asked for the err-5 fallback to
be tested against current firmware. Every branch of it is unit-tested, but
verifying it on a real radio is bench work on his hardware, and the fake-radio
harness is still only a PLAN on dev, so there is no software radio to drive it
against. Said plainly as a handoff rather than left to look complete.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read from firmware src/helpers/ChannelNotify.h on epic/606-channel-notify
rather than taken from Agent Mail: kChanNotifyUnset = 0, All = 1,
Mentions = 2, Off = 3, Count = 4. The client enum already matched byte for
byte, so nothing changes in behaviour; this records that it was checked
against the source, since taking numbers from mail is what caused the
collision in the first place.
The contract names the modes All / Self / None plus 'follow the device
setting'. That is the same encoding in different words: Self is mentions,
follow is unset. Worth saying so explicitly, because two vocabularies for one
wire byte is how a later session talks itself into a renumber.
Also records WHY the rotation collision was silent rather than refused:
firmware validates a SET with 'stored < kChanNotifyCount' and nothing else, so
every rotation value from 0 to 3 passes as a valid notify mode and writes to
the channel at that slot index. Slot 0 is Public.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three decisions are his: push/PR, whether feat/523-display-indicators gets
rebased onto the rotation fix, and whether the CHAN_NOTIFY gating precedence I
shipped stands if firmware rules otherwise.
Records what I could not finish and why, rather than reporting the night as
clean: the silent-corruption path still exists on ChartreuseGate's branch,
which is checked out in their live worktree and so cannot be committed to from
here. The fix is on a derived branch instead so their work is neither blocked
nor overwritten.
Also records a repair I made to his machine. Every Flutter Android build on
this box was failing, flutter doctor included, from a clean tree with none of
my code involved: Android Studio updated and left AndroidStudio2025.3.1 as a
half-removed cache directory with no .home, and Flutter's install scan chokes
on it. I created that one file. Additive, reversible, and named in the doc so
he can undo it.
And the guard that lied: the first offset-87 test compared only named
capability getters, so a leak into a caps bit with no getter yet moved nothing
it could see, and it PASSED with a planted defect in place. Strengthened to
compare the raw caps byte, re-planted, went red. Recorded because a green bar
on an unproven guard is the failure mode, not the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Realigns the client to the merged contract. Firmware
docs/offband_info_frame_contract.md revision 2 and OffbandConfigProtocol.h,
both on firmware-base; the header is the registry and wins on any
disagreement. Wire numbers claimed by firmware PR #2000, merged 28785ebf at
2026-11-04 08:34:29Z. Nothing here was built before that merge, per contract
section 2.
Three features each computed "the next free byte" of upstream's device-info
frame as 87 and each was right in isolation. A collision at a frame offset is
silent misparsing, not a refused command: everything compiles, every test
passes, and it only shows up on hardware as one feature appearing broken.
#1095 the advert client
#1096 offset 87 removed, with a guard
#1097 channel notify re-gated
One request on connect, zero payload, after device-info has landed and so well
past MTU negotiation. The client asks and the device never pushes: _att_mtu is
23 until onMTUChange fires, so a pushed advert would be truncated to a 20-byte
payload as the normal case.
A tag's presence IS the capability. No bitmap, no probing each feature's
sub-code. len 0 means supported with nothing to report; an absent tag means the
board lacks the feature and is neither an error nor a zero value. Unknown tags
are kept and skipped by len, because a client built tonight has to stay correct
against firmware emitting tags it has never heard of. TAG_CONTINUE is honoured,
bounded at 8 rounds and refused a cursor that does not advance: an unbounded
request loop driven by device data is worse than a truncated capability list.
[0xC5][0x7F][0x05] to the zero-payload request means the radio predates the
advert, so capabilities fall back to the device-info offsets that shipped
before it. That reason is overloaded on firmware (malformed payload on a known
sub-code returns it too), which is exactly why the request carries no payload.
The refusal names no sub-code, so it is claimed only while an advert request is
outstanding AND no write is: a write owns its own refusal.
Channel notify gating is PRECEDENCE, not OR, and this part needed a ruling.
Both caps2 0x80 and TAG_CHAN_NOTIFY are merged claims and the contract does not
say which wins. Both single-source readings break on a real radio: tag-only
takes the control away from 4e5a6c85, already flashed on the T1000-E, which has
the bit and predates 0x1C; bit-only loses the control when the bit is retired
under contract section 4. So an advert, once it arrives, is authoritative and
the bit is ignored, and with no advert the bit answers, which is what shipped
before. Raised with FuchsiaCreek before coding rather than worked around
(Agent Mail 1449); one expression to invert if the ruling differs.
Rotation and ACK capacity gain their gates here but not their features: #1098
and #1099 follow. Rotation is tag-gated with no caps-bit fallback, deliberately
-- 0x1A/0x1B were claimed in the same merge as the advert, so no firmware can
rotate but not advertise -- and explicitly NOT on caps2 0x04 INDICATORS, which
means something else.
parseOffbandCaps3, offbandCaps3Offset and offbandCaps3MinFrameLength are gone
rather than left unused, so a later change cannot pick them back up. The
replacement is behavioural: a device-info frame with every bit set at offset 87
must produce identical capability answers to one that has no byte 87 at all,
the raw caps byte included. The getter list alone was a hole -- a leak into a
bit with no getter yet moved nothing the test could see -- found by planting
such a read and watching the test pass, then comparing the raw byte as well.
Each new guard was made to fail before being trusted: the unknown-tag skip
(parser dropping unknown tags, 2 red), the precedence rule (gate rewritten as
an OR, 1 red), the offset-87 guard (a read reintroduced, 1 red after the
strengthening above).
flutter analyze clean. 1869 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>