On connect, the block reconcile unioned the client's block list with the
radio's and pushed the difference, so a fresh/erased radio (empty list) had
every client block offloaded onto it and came up pre-blocked (#471, owner
verdict ERROR). Owner decision: Option A. A radio that reports an EMPTY block
list is treated as blank and is NOT auto-offloaded; the client stays the source
of truth and blocks reach a radio only by explicit user action. A radio that
already holds blocks still unions normally. Client-side only, no wire change.
Added a setOffbandCapsForTest seam so the reconcile is testable.
Part of radio-profile isolation Feature #853. The per-radio Blocked view +
provenance (#507) is the follow-on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Discovered contacts (nodes heard but not added) were stored under one global
'discovered_contacts' blob shared by every radio, so one radio's discoveries
showed up while another was connected (#754, Feature #853). Scope the store by
the connected device-key prefix like contact_store and the others; the connect
block sets the key and the existing startup load clears + reloads the list for
that radio. An unset key loads nothing and never writes, so it can't read or
clobber the legacy shared blob. No migration: the legacy blob is orphaned and
each radio's list self-heals as it re-hears adverts.
Part of radio-profile isolation Feature #853.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. existingFiles() walked hardcoded names with per-name exists(), so the periodic flush could rotate mid-walk and return a name it had just confirmed and rotation then renamed. Handing the share sheet a path that no longer resolves fails the WHOLE export, which is the exact failure the rotated-log fix existed to prevent. Now one directory listing, filtered and ordered by name. flushAndGetAllFiles() degrades to the active file if listing throws, because the logger cannot report its own listing failure anywhere, and one log beats none. shareLogs() also re-checks existence at the last moment, since a trace can be pruned between collection and share.
2. forceDartIsolateBlock used DateTime.now() for its deadline. The wall clock can move backwards, which would push the deadline away and leave the app frozen past the window or indefinitely, needing a force-kill on a test build. Now a Stopwatch, which is monotonic, matching what the Kotlin block already did with nanoTime.
analyze clean, 1307 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The existing trigger hangs the Android main thread, so its trace is a Java stack. That proved the capture works and that our Kotlin frames resolve via the R8 mapping, but it cannot say whether our own DART frames survive into a trace, which is the question #906 was filed to answer.
This one blocks the Dart UI isolate the way the real ANRs do rather than with an artificial spin: it repeatedly JSON-encodes a large nested structure shaped like the stores #334 is about. The Play clusters show the real ANRs inside the JSON stringifier and under allocation pressure, so a trace from this block should match their shape, and whether any of our Dart frames appear above those is then readable.
It spins deliberately. Any await in the loop would yield to the event loop, the isolate would keep answering input, and no ANR would fire. If the process survives the full duration it logs why, so the tester is not left guessing.
Gating is unchanged and still doubled: the bool.fromEnvironment constant keeps both rows out of any shipped build, and the owner-only Experimental section hides them again in a test build. Both rows are relabelled native side and Dart side so they are distinguishable.
Tests: both triggers asserted to be immediate no-ops off Android, which matters because a missing platform guard would hang a desktop build instead of failing visibly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. The export shipped only the active log, and FileLogWriter._rotated was private, so offband.1.log and .2.log were unreachable by anything. An exit-record pointer line written at startup rotates out after a couple of MiB of ordinary logging, handing over a trace file with its metadata gone. FileLogWriter now exposes the files that exist, keeping the rotation naming where it belongs, and the mobile export attaches all of them.
2. dispose() called ioExecutor.shutdownNow() while a read could be holding a MethodChannel.Result. Interrupting it left the Result neither succeeded nor errored, so the Dart future never completed. Now shutdown(), with a disposed flag and a RejectedExecutionException path that always replies.
3. The trace read was skipped for REASON_CRASH_NATIVE on the assumption that its stream is always a protobuf. AOSP says a trace from an earlier ANR stays attached even when the process later died for another reason, so gating on the reason loses real text traces. The read is now attempted on every record and a printable-bytes check decides whether the content is usable.
4. The test-only main-thread block used a single Thread.sleep, which an interrupt could cut short: no ANR, no record, and a wasted trip to the hardware. It now sleeps to a deadline and ignores interrupts, which guarantees the duration without a busy-wait's CPU burn.
Review also flagged that FileLogWriter can exceed maxBytes for a single oversized entry. That is pre-existing writer semantics, already pinned by a test in this branch, and deliberately not changed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It said hanging the UI makes Android raise an ANR. That is not true on its own. Android's documented trigger is 'has not responded to an input event within 5 seconds', and it documents no time-based trigger for a blocked main thread, so a freeze with nothing pending just recovers: no ANR, no process death, and no exit record to read back.
The subtitle now says to keep tapping during the freeze, which is what actually queues the unanswered input the dispatcher measures. Without that the tester taps once, waits, sees nothing happen and reasonably concludes the feature is broken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Exit records are Android-only, so on a desktop build the dart-define alone would leave a button that does nothing. The row now needs the flag and a supported platform.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
43 tests. Selection covers the dedup contract: strictly-newer only, a record equal to the watermark treated as already logged, two records in one millisecond both kept, the watermark taking the highest rather than the first, and the backfill trace cap.
Formatting covers the cases that would otherwise read wrong: zero pss and rss render as unknown rather than as a real measurement, a null description is omitted rather than printed, an absent trace records why, and a backfilled record says so. Every message the service can surface is asserted present for its own failure, and an ideal line is asserted to carry none of the failure markers.
The channel layer runs behind a mock handler, which proves the watermark advances on success, does not move on a query failure so the next launch retries, and that collect never throws and runs once per launch.
What these cannot prove, stated in the test file header: that Android actually produces a REASON_ANR record with a readable trace. That needs a real main-thread block on an API 30+ device and a relaunch, which is #911 part B.
The rotation tests pin down what a 40 KB entry actually does, which is the Epic's rotation criterion: it rotates once and is written whole, the active file is then legitimately above maxBytes because that gate decides when to rotate rather than truncating, and maxFiles still holds so the log cannot grow without bound.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hanging the main thread is the only way to produce a real REASON_ANR record to read back. No adb command induces an ANR: am crash produces a crash, and kill -3 dumps traces without a REASON_ANR death. So the test needs a trigger inside the app.
Gated twice. The Experimental section ships in release behind a 7-tap unlock, which is not enough for a control whose job is to hang the app, so the row also sits behind a bool.fromEnvironment constant and is compiled out entirely unless a build passes OFFBAND_ANR_TEST. That flag is passed on the command line, never added to dart_defines.json, which feeds every build including Play.
It is an action rather than a persisted toggle, so there is nothing for disableExperimental() to clear and app_settings.dart is untouched. The label is deliberately not localized, since it never reaches a user; the Observer rows in the same file set that precedent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shareLogs() exported exactly one file, the active offband.log, and the rotated .1.log and .2.log were never exported at all. So a trace written anywhere other than inline would never have reached anyone, and a trace written inline would later rotate out of the only file that ships.
SharePlus already takes a list of files, so this attaches the trace files beside the log with no new UI and no new localized strings. Mobile only: the records are Android-only and the desktop path saves a single chosen file.
Without this the Epic would ship and deliver nothing, which is why it is part of the build rather than a follow-up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reads the records over the channel, logs one line per record whatever its reason (the owner's scope ruling on #908), and writes any ANR trace to a file beside offband.log.
Dedup is a single persisted integer, the highest exit timestamp already logged. Records come back newest first, so a pass takes everything strictly newer than the watermark and then advances it to the highest seen; two records sharing a millisecond are both taken in that same pass, so neither is lost. A missing watermark is treated as a capped backfill and labelled as such, so a months-old ANR is not misread as fresh.
Scheduled in a post-frame callback and deliberately not awaited, so startup time is unchanged. Every failure path writes a line and none is silent; a failed query leaves the watermark untouched so the next launch retries. An explicit flush follows the writes because FileLogService batches on a 2 second timer, and without it a startup line can be lost, leaving a trace file with nothing pointing at it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reads the system's record of why this app's processes died, so an ANR that killed the process is recoverable on the next launch (#906).
getHistoricalProcessExitReasons is API 30 and minSdk here is 24, so the path reports itself unsupported below that rather than failing. The query and the trace reads run on a background executor: #334 is the leading hypothesis for these ANRs, so a collector that blocked the main thread would add to the problem it exists to measure.
A trace is not guaranteed even for REASON_ANR. AOSP keeps traces in a circular buffer shared across all applications, so another app's crash can evict ours, and a trace can ride on a record whose reason is something else. So the read is attempted on every record and absence is an ordinary outcome, not an error. getTraceInputStream() is declared throws IOException, so the call itself is inside the try, not only the reads on the stream it returns.
REASON_CRASH_NATIVE yields a protobuf tombstone rather than text from API 31, so its bytes are deliberately left alone and only the metadata is recorded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The numbers, and the two places the implementation deliberately differs from
what this plan said: the blob is never deleted rather than deleted after a
verified readback, and a row holds the per-message JSON rather than a column
per field. Both are narrower or stricter than the plan, and a plan that does
not say where it was departed from is not a record.
Citadel: meshcore-open-i8t
Refs #920, #917
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ran the migration over a copy of the live 41.17 MiB database, 138 message
history keys:
keys migrated : 138 of 138
records in blobs : 64,883
rows stored : 64,883
elapsed : 4,382 ms
Exact parity, no refusals. Every blob is byte-identical afterwards, checked
by length and content hash per key, which is the promise that makes this
recoverable.
Per-message save cost against history length, median of 20:
500 messages 0.124 ms
5,000 0.126 ms
50,000 0.133 ms
A hundred times the history for 1.07x the cost. The Feature's "done when"
was that receiving a message costs the same at 50,000 messages as at 50, and
that is now true. For contrast, the blob path blocked the UI for 381 ms at
9,230 messages.
One-time migration cost for the worst key, 5.38 MiB and 9,230 messages, with
the heartbeat harness from #919:
UI blocked 64 ms wall 447 ms (30 heartbeats)
So the first open of the biggest channel after this ships costs a 64 ms
hitch on this desktop, once, and the UI stays live through the rest.
That number needed a correction before it could be reported. The first run
measured 166 ms, because the harness opened the database with a plain
`NativeDatabase`, which executes SQL on the calling isolate. Production uses
`drift_flutter`'s `driftDatabase()`, which hosts the connection in a
background isolate, so the harness was charging the UI isolate for work that
does not happen there. Switched to `NativeDatabase.createInBackground`,
which is the shape that ships: 64 ms.
The parity test refuses to invent data: without `--dart-define=BENCH_DB` it
prints that it measured nothing rather than synthesising a database, and it
copies the file it is given rather than writing to it.
Citadel: meshcore-open-i8t
Refs #920, #917, #334
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A save is now one upsert per message in the list being saved, and costs the
same with 50,000 messages of history as with 50. That is the Feature's
"done when", and it is the part #919 could not deliver.
Also gone: a delete is a single-row DELETE instead of rewriting the whole
history, and a load reads only its own key's rows, ordered and pageable in
SQLite.
Load order matters and is deliberate. Rows are consulted first, but ONLY
when rows actually exist. A key marked migrated with no rows still falls
through to the legacy slot-index and unscoped-key adoption (#194, #306),
because those run on blobs; short-circuiting on the marker alone would strand
history stored under an older key. Adoption then hands its result to the
migration, so the key ends up on rows either way.
The blob path stays as the fallback for a key whose migration was refused.
Refused keys keep working exactly as they did after #919: off the UI isolate,
merge-not-overwrite, failing closed on an undecodable blob. Tested, including
that a refused key does not accumulate half a history in rows.
One unreadable row now costs one message. On the blob a single bad byte took
the whole conversation with it.
Clear paths had to learn about rows: a cleared channel or conversation that
only dropped its blob would have stayed fully readable. clearAll also sweeps
row-only keys, which no longer appear in a blob key listing.
`encodeHistoryForTest` is new on both stores. The #919 tests built their
blob fixtures by saving through the store and reading the blob back, which
cannot work once saves write rows, and hand-written fixtures are what rotted
the first time. They now call the stores' own encoder directly.
Tests: 24 added, through the public store API only, because that is what an
existing install hits on its first launch after this ships. Covered: blob
adoption on first load and on first save, the windowed-save truncation case
(#343), per-radio and per-contact scoping (#472), delete, clear, clearAll on
row-only keys, a refused migration still serving reads and writes, and the
payload being the stores' own encoding.
Suite: 1,344 pass, analyze clean, format clean.
Citadel: meshcore-open-tfq
Refs #920, #917, #334
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per key, once: count the blob's records, convert it to rows off the UI
isolate, insert them in one transaction, read the stored count back, and
only then mark the key as reading from rows.
**The blob is never deleted**, not even on a verified match. Rows become the
read and write path; the blob is left untouched. Reclaiming that space is a
separate owner-gated step, because the moment the delete runs is the moment
a mistake stops being recoverable, and a downgrade to an older build still
finds its history where it looks for it.
Every failure leaves the blob authoritative and says so loudly: an
undecodable blob, a converter that throws, a count mismatch, a failed
insert. None of them retries, because a count that did not match once will
not match on a loop.
The migrated set is its own marker key, not "this key has rows". Those are
different statements: a key can have rows and an incomplete migration at the
same time, and treating rows as proof of completeness is how half a history
would read as a whole one. An unreadable marker treats every key as
unmigrated, which is the safe direction because the blobs are all still
there.
The first version of the readback gate was a check that could not fail. It
compared the stored row count against the number of rows the converter
produced, so a converter that silently dropped records matched itself and
passed. Caught by a test built to drop a record. The blob's element count is
now measured by a separate decode that does nothing but count, so the
converter cannot report its own homework: `rows.length` must equal that
count, and the stored count must equal the distinct identities. Both can
fail independently, and together they close the loop from blob element to
stored row.
Duplicate identities in a source blob are deduped rather than refused. The
old merge should not have produced any, but the legacy slot-index adoption
path (#194) could have, and that must not read as data loss.
Tests: 15, nine of them failure paths. The lossy-converter case is the one
that caught the broken gate.
Citadel: meshcore-open-x9q
Refs #920, #917, #334
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Schema 2 adds one row per message beside StoredBlobs, which stays exactly as
it is. A save becomes an upsert of one row, so its cost stops depending on
how much history is already stored. That is what #919 could not do: it moved
the whole-history re-encode to another isolate, it did not remove it.
Shape, and why it is this shape:
store_key the EXISTING blob key, byte-identical
merge_key the stores' existing _mergeKey identity
timestamp_ms INTEGER, so ORDER BY and LIMIT run in SQLite
payload the per-message JSON the stores already emit
PK (store_key, merge_key), index (store_key, timestamp_ms)
Reusing the blob key means every per-radio and per-channel scoping rule
(#194, #472) carries over untouched, and the migration becomes a per-key
operation with an exact record count to verify against.
The payload is JSON rather than a column per field. A Message has 25 fields
including three maps, two byte arrays and the translation set; hand-mapping
them would mean trusting a new field-by-field converter against 41 MB of
real history on its first run, and #355 cost 574 messages the last time a
storage migration was hurried. With a payload column the migration is a pure
re-key, the stores' existing encoders are reused verbatim, and a new message
field needs no second migration. The trade is that message fields are not
SQL-queryable; nothing asks for that today, and columns can be added beside
the payload later without another data migration. Flagged for the owner on
the issue before building it.
The row API lives on BlobStore rather than in a second class because both
APIs must share one OffbandDatabase: opening it twice is an error on the web
backends, and a second singleton would be one mistake away from doing that.
`limit` with no `offset` returns the NEWEST n, still oldest-first, because
that is what a chat screen opens with.
Tests: 18. The row set covers store-key scoping (a leak there would be a
history bleed between radios), merge-key upsert, paging, per-row delete, and
rows coexisting with a retained blob. The schema set builds a database that
looks like one an older build left behind, v1 table with real data and
user_version 1, and proves the upgrade runs, creates a usable table, leaves
the blobs byte-identical and lands the index on both an upgraded and a fresh
database.
Citadel: meshcore-open-g6j
Refs #920, #917, #334
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan gated the smaller stores on measurement. The measurement came back
"no", so the plan says so, with the numbers and with the two findings that
fell out of taking it (#923, #924). A gate that resolves silently is not a
gate.
Citadel: meshcore-open-xxc
Refs #919, #917
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the Epic needed and did not have: a gate that fails if the work
comes back to the UI isolate, and a number for how much it was blocking.
The gate is structural, not timed. The merge callback reports the isolate it
ran on, and the test asserts that is not "main". `compute` names the worker
it spawns after the callback, so the recorded name also says which worker.
A wall-clock threshold would have been flaky on CI and would not say why it
passed.
The benchmark runs a 1 ms heartbeat on the main isolate and reports the worst
gap between beats, which is the UI freeze an ANR is. It is named `_bench`,
not `_test`, so a bare `flutter test` skips it: it needs a local copy of a
real database and the numbers are machine-dependent.
Measured against the owner's real largest channel, 5,650,194 chars (5.39
MiB), 9,230 messages, on this desktop:
UI blocked wall heartbeats
1 save, inline (pre-919) 381 ms 381 ms 1
1 save, off-thread 44 ms 297 ms 174
5 saves, inline 1,385 ms 1,385 ms 1
5 saves, off-thread 66 ms 1,496 ms 964
8.7x less blocking on one save, 21x on a burst of five. Wall time barely
moves, which is the plan's point: the work is not cheaper, it is elsewhere.
Inline, UI-blocked equals wall and one heartbeat lands in 1.4 seconds; the
UI isolate was gone for the whole operation. Off-thread it beats 964 times
through the same burst.
The first measured support for the burst claim, which until now was
arithmetic. Still desktop, not a phone: that is the owner's device check.
The heartbeat needed a fix before it could be trusted. A periodic timer
cannot fire while the isolate is blocked, so cancelling it the moment the
action returns discards the tick that would have shown the gap, and the
first run of this benchmark duly reported the inline path blocking 0 ms.
Verified on a bare 200 ms busy-wait: no trailing yield gives fires=0,
worst=0 ms; with a yield it gives 200 ms. It now yields before cancelling
and reports the heartbeat count so a dead instrument cannot read as a clean
result.
Citadel: meshcore-open-xxc
Refs #919, #917, #334
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deleting one message rewrites the entire blob, so on the owner's largest
channel a delete blocked the UI for as long as a save did, right after the
user tapped something. Both delete paths now go through
`mergeOffThread`.
Two call shapes, one helper. `mergeOffThread`'s second argument is renamed
`incomingJson` to `payload`, because the callback decides what that string
means: the incoming batch when saving, the target merge key when deleting.
The method only carries it across the boundary.
`dropFrom{Channel,Dm}HistoryJson` return null when the target is not in the
history, so nothing is written at all. Re-encoding 5 MB to change nothing is
exactly the cost this Epic exists to stop paying, and the old code did it on
every miss.
`MessageStore.removeMessage` also gains per-key serialisation it never had:
it was a read-modify-write outside `BlobStore.synchronized`, so a save
landing after a delete could resurrect the deleted message. There is a test
for that race.
Tests: 8 added in their own file, including both stores' public delete API,
an absent target, an absent blob and a corrupt blob left byte-identical.
Citadel: meshcore-open-z7c
Refs #919, #917, #334
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`MessageStore.saveMessages` had the same read-modify-write as the channel
store: decode the whole DM history, rebuild every record, re-encode it, all
on the UI isolate, for every single message that arrives.
Applies A1's helper. Only the incoming batch is encoded on the UI isolate;
the decode, merge and re-encode go to another isolate via
`mergeDmHistoryJson`, a top-level function so `compute` can send it. The
five JSON helpers it needs are now static for the same reason.
`mergeDmHistoryJson` throws on undecodable stored JSON rather than
swallowing it, which `mergeOffThread` turns into a refused write. That is
the same refusal the inline code made, and it is what stops a 5 MB history
being replaced by the one message that happened to arrive.
Tests: 6 added. Fixtures encode through the store's own encoder, never a
hand-written map, because a hand-rolled fixture rots the moment a non-null
field joins the schema. Includes a save round trip proving the second save
merges rather than overwrites (#343).
Citadel: meshcore-open-8sg
Refs #919, #917, #334
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BlobStore.mergeOffThread runs the whole read-modify-write through compute(): stored JSON and incoming JSON in, merged JSON out, so only strings cross the isolate boundary and the object graph never does. Keeps the per-key serialisation that stops concurrent saves clobbering each other.
channel_message_store.saveChannelMessages now encodes only the INCOMING messages on the UI isolate (normally one message, measured 0.002 ms) and hands the 5.38 MB decode-merge-encode to another isolate, where it measured 134 ms per save and crossed Android's 5s input-dispatch watchdog in a burst (#334).
Fails closed: a stored blob that will not decode throws out of the merge, is logged, and the key is NOT written, which is the same refusal the inline code made. compute runs inline on web, which has no isolates; that is documented behaviour, not a silent fallback, and #920 removes the cost for every platform.
_messageToJson, _messageFromJson, _mergeKey, _repeatToJson and _repeatFromJson became static so the top-level merge can call them; none touched instance state.
9 new tests cover merge equivalence, timestamp ordering, empty/absent blobs, the throw on corrupt JSON, fail-closed leaving the blob byte-identical, and three concurrent saves all surviving. Fixtures go through the store's own encoder rather than hand-written maps, after a hand-rolled fixture rotted on a non-null field.
Refs #334, #917
Agent: TealGlen (session c61ce804)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first thing the restored CI caught. `flutter build linux --release`
fails two seconds into the native compile:
once_flag.h:24: typedef redefinition with different types
('__once_flag' vs 'int')
stdlib.h:1195: conflicting types for 'pthread_once'
It is the runner image, not a code change. The same build passed on
ubuntu-24.04 at 05:52Z today and failed on ubuntu-26.04 at 15:36Z; GitHub
moved the ubuntu-latest label between those two runs. A typedef clash
against /usr/include is a vendored-header-versus-system-glibc conflict,
which is what a glibc bump produces.
Pinning buys time and fixes nothing: 24.04 images get retired, and the
conflict has to be resolved before that. #929 stays open for it and names
the native-assets step (#445, sqlite3 and llamadart) as where to look.
The other ubuntu jobs pass on 26.04 and stay on ubuntu-latest, so this is
one line of behaviour change.
Refs #929
Citadel: meshcore-open-83o
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`SPIKE_FORCE_DOCS_ONLY: "true"` reached dev in 88ea3d19. Its own comment
said it "must never reach dev" and would be removed in a follow-up commit
that never landed.
Every run since then has short-circuited: classify reports docs_only=true
without ever looking at the diff, so analyze, the test suite and all seven
platform builds report **pass** in three to six seconds having built and
tested nothing. #903 merged on top of it. #925 and #926 are both showing a
green that means nothing, noted on both.
The env is gone and so is the `if` block that read it. Leaving the guard
would keep a "skip every merge gate and report success" switch one
environment variable away, which is exactly how this happened.
This PR's own CI is the proof: with the flag gone, classify has to reach the
diff, see .github/workflows/*.yml, classify it as code, and run the real
jobs. A fast green on this PR means the fix did not work.
Closes#927
Citadel: meshcore-open-9b1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first version was invented YAML in an untagged fence. grant.py requires a single fence tagged grant-terms containing JSON with keys epic_chain, chain, verification, merge, and optionally flash/expires_after_hours.
epic_chain is a list of distinct ISSUE NUMBERS, so the Epics had to exist first: 919 (A, isolate offload), 920 (B, normalise to rows), 921 (C, integration testing). Verified with grant.parse_terms against the file: zero errors.
Refs #334
Agent: TealGlen (session c61ce804)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured diagnosis: StoredBlobs holds one JSON blob per key; saving one message decodes, rebuilds and re-encodes the whole channel history. 134 ms per message at 9230 records against 0.002 ms for an append, so 67000x more work than needed, scaling ~14 ms per 1k records.
Epic chain: A moves the whole read-modify-write into one Isolate.run (relief, no data risk); B normalises to one row per message (removes the cost); C integration-tests both on hardware.
Carries the cannot-tell branches table and grant terms with the basis for every budget. Also records that #335 did not fix this and that drift already runs SQL off the UI isolate.
Refs #334
Agent: TealGlen (session c61ce804)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(#881): frame 66/29 CLI codec with cc| correlation
Pure codec per the 1.18 contract: ASCII fail-closed builds, lenient reply decode, truncation bound judged on the raw payload. Gemini findings 1+2 fixed, 4 documented.
Agent: GentleGlacier (session f0c92a36)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(#882): local CLI service + shared cc| correlation helpers
PrefixAllocator and splitCorrelated extracted; the repeater service delegates unchanged in behavior. LocalCliService runs correlated frame-66 round trips; the connector routes RESP 29 to listeners.
Agent: GentleGlacier (session f0c92a36)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(#883): device CLI console screen
The local-device counterpart of the repeater CLI screen (interop plan
F2/E2.1): type a command, it goes to the connected radio over frame
66/29 via LocalCliService, and the correlated reply renders with a
per-reply latency chip. No path/flood selection and no retries - one
send, one frame-level timeout window surfaced as its own row.
Built to the owner-approved behaviors: masked secret replies
(long-press to copy, cleartext never in the widget tree), the 165-byte
truncation badge, string-matched error styling, and command history.
Entry point and population gating are #884, deliberately not here.
Security fix surfaced by the #883 Gemini review: LocalCliService logged
the cleartext of an unmatched (late) reply, so a secret reply arriving
after its command timed out could leak to the app log despite the
screen's masking. The log line now withholds the reply text (token +
byte length only); a test pins it.
Also replaces em-dash characters in the sibling E2.1 files flagged by
the #464 CI guard.
Tests: 12 widget tests for the screen and a service test for the log
redaction. Full suite green (1207 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(#902): declutter Settings - user-ops to Actions, reorder Debug
Owner-directed Settings hygiene, split from the #884 entry point so the
Device CLI row lands in a clean Debug pane.
Moved to the Actions pane (they are user operations, not diagnostics):
config Export/Import and the three GPX exports (the whole export card),
plus "Sync queued messages now".
Debug pane reordered to App Debug Logging, Companion Debug, App Debug,
Serial Capture, Mesh Topology (which stays - useful diagnosing with a
user). The Device CLI row at the top is #884, not here.
Behavior preserved: every row keeps its handler, icon and copy; only
their pane and order changed.
Tests: a new widget test pins the spec - the moved rows present under
Actions and absent from Debug, and the full Debug order. A Gemini review
finding of a Debug-ordering defect was refuted (the order matches the
spec; the review misread the toggle for the viewer); its secondary point
was taken and the test now pins the full order. Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#884): Device CLI entry point + capability gate
Adds the entry point for the console (screen landed in #883) to
Settings > Debug. A "Device CLI" row appears at the TOP of the Debug
pane only when the classifier places the connected radio at stock v14+
(classification.allowsCliFrame): stock 1.18+ today, Offband at the
upstream-1.18 absorb via the D1 seam. The row is absent on stock-legacy,
Offband, and when disconnected (classification null). Tapping it opens
DeviceCliScreen.
The gate reads the capability through context.select, so only that
boolean flipping rebuilds the pane, not every connector update (#81).
Depends on the classifier from dev (merged into this branch).
Tests: positive (stock 1.18 shows at top, opens the console) and three
negatives (disconnected, stock-legacy ver 13, Offband all hidden). Per
the Gemini review the test's frame helper now asserts the version fits.
Full suite green (1244 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#881): dart format the codec test (CI format gate)
test/cli_command_codec_test.dart (landed in #881) was not formatted, so
the branch's first run of the "analyze" job (flutter analyze
--fatal-infos + dart format --set-exit-if-changed .) failed on it.
No logic change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#885): mirror set-name character rejection + CLI l10n
The firmware's `set name` rejects the characters [ ] \ : , ? * and
bounces a bad name as a cryptic ERROR. Mirror that client-side so the
name is caught before it is sent, in both places a name is set:
- Settings node-name dialog: live inline error, Save blocked on an
invalid name (nothing sent).
- Device CLI console: a `set name <value>` with a forbidden char is
echoed with a rejection row and never sent; the device stays the
authority for everything else.
Shared validator in lib/helpers/device_name_validator.dart
(forbiddenCharsIn is distinct + canonical-order). Also moves the
"Sync queued messages now" row strings and the new validation messages
onto app l10n (EN copy unchanged).
Tests: validator unit (the exact seven chars, order-stable, deduped),
console pos/neg (+ rejected command still recallable from history, per
the Gemini review), and the Settings dialog pos/neg. Gemini review
otherwise clean; its one finding (rejected command not in history) was
refuted - history derives from the transcript, which the rejection path
appends to - and pinned with a test. Full suite green (1255 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: diffwireauto <330573775+diffwireauto@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Build phase of #904 (plan owner-AGREED 2026-10-19).
Adds a `classify` job to build.yml and flutter_dart.yml (plain in-repo git
diff, no third-party actions) and conditions every step of the 7 heavy
required checks on its docs_only output. Jobs always run and always report
their required check names; on a docs-only diff every step is skipped and a
marker step states why, so the check is an honest fast SUCCESS, never a
pending ghost.
Allowlist (owner-approved on #904): **/*.md, docs/**, LICENSE,
.github/ISSUE_TEMPLATE/**. Everything else is code, all .json included;
empty or unreadable diffs classify as code, so a wrong classification can
only waste a build, never skip one.
THIS COMMIT CARRIES SPIKE_FORCE_DOCS_ONLY=true in both classify jobs: the
machinery PR's own .yml diff would classify as code, so the force is the only
way to prove the short-circuit semantics against the real required checks on
a real dev-based PR. The follow-up commit removes the force, and that same
PR run doubles as the negative test (full matrix on a code diff). The force
must never reach dev.
The em-dash guard stays inside the short-circuit: verified it scans only
lib/ and test/ Dart files, so a docs-only diff cannot contain what it hunts.
That .md files are outside its scope at all is a pre-existing gap, noted on
the PR, not widened here.
gate (release-gate.yml, ~7s) is deliberately untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Channel notify modes lived in one global AppSettings map keyed by PSK or a
bare slot index, so one radio's settings bled into another and set-and-back
could not clear a stale alias (#839). Scope them by the connected device-key
prefix like every other per-radio store (prefix -> pskHex -> mode); resolve
All when the radio or PSK is unknown instead of falling back to the slot
index; store a single entry per channel so All truly resets. One-time
migration moves the legacy flat map and name-keyed mute set into the first
radio synced after upgrade; later radios start clean. Removed the now-unused
legacy muteChannel/unmuteChannel/isChannelMuted methods so mutedChannels is no
longer a second, cross-radio source of truth (pre-PR Gemini review finding).
Buzzer Inherit (#544/#545) reads the same path and inherits the fix.
Epic #844. Build #848.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment-only; the analyze job's em-dash guard scans source files. Part of epic 866 (container not named in the scope per the push gate).
Agent: GentleGlacier (session f0c92a36)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Promise table, signal precedence, marker requirements for the firmware D1 decision, known fail-closed limitations.
Agent: GentleGlacier (session f0c92a36)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven comparison sites dispositioned: pre-split floors (>=8, >=13) stay bare by design; fork floors are caps-anchored and now covered by a stock-ver-22 regression test. Channel-data bound is vacuous: the client never sends CMD_SEND_CHANNEL_DATA.
Agent: GentleGlacier (session f0c92a36)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device-info handling classifies the radio before reading any Offband byte; a conflict nulls caps/caps2/FEM and logs why. Capability bytes now also die on disconnect, like the ver code.
Agent: GentleGlacier (session f0c92a36)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Classifies the connected radio (stockLegacy / stockModern / offband) from device-info frame shape + ver code + version string; fails closed on conflicts and malformed input per the plan's can't-tell table. Gemini findings 1A/2 fixed (uncorroborated fork shape and inverse conflict both fail closed); finding 3 declined with justification on the issue.
Agent: GentleGlacier (session f0c92a36)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Initiative plan (4 features, 9-epic chain, grant terms, can't-tell table, owner decisions D1-D6 with recommendations) plus the three-surface contract investigation it derives from. Owner approved the plan in chat 2026-10-17; D-recommendations stand unless overruled on the PR.
Agent: GentleGlacier (session f0c92a36)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
D1: first Discover attempt sometimes drops (normal LoRa packet loss); the retry gets through. Per owner (Option A), discoverRegions now makes up to 2 attempts (_discoverRegionsOnce), each using the firmware est_timeout; an unreachable repeater still ends in a timeout after both. Test: first attempt times out, second succeeds, two sends.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
D1: the picker excluded direct repeaters. hasPath required a non-empty path, but a direct 0-hop repeater has pathLength 0 and an empty path, so the owner's own adjacent repeater was greyed as 'No stored path'. Direct is a reachable (best-case) route; hasPath is now pathLength >= 0, excluding only pathLength -1 (flood/unknown). The reply-path already sends zero-hop for direct, which is correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The picker lists only repeaters with a stored path (the only ones that can answer), with a header note explaining that. Search surfaces pathless matches too, but greyed out and non-selectable with a 'No stored path' label, so the user sees the repeater exists and why it is blocked. Default selection skips pathless repeaters; Discover is disabled for them. Widget test covers hidden-by-default + greyed-on-search.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Discovery no longer caps the reply to zero-hop. discoverRegions now sends the repeater contact's stored path as the reply path (the firmware routes the reply back along it as-is, Mesh.cpp:176-177), so a multi-hop repeater can answer. The discovery timeout re-arms to the firmware's own est_timeout from RESP_CODE_SENT (scales with hops) instead of a fixed 10s. The repeater list is never filtered on path/hops, since stored paths go stale as the companion moves (owner guidance). An unknown/stale path still surfaces as a plain timeout, not a silent drop. Test covers the path being used as the reply path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
D1 found Discover Regions got no reply from the owner's repeater. Root cause (firmware BaseChatMesh.sendAnonReq): the firmware prepends its own 4-byte tag, but buildAnonRegionsRequestFrame also prepended a timestamp, shifting the request-type byte so the repeater dispatched it as a login, never regions. And discoverRegions correlated on a client-invented tag, not the firmware tag the 0x8C echoes. Fix: request payload is [0x01][reply_path_len] (no timestamp); discoverRegions learns the tag from the RESP_CODE_SENT ack via the frame stream and matches the 0x8C by it. Removed the dead tag map + 0x8C switch case. Tests corrected to the real contract (they had encoded the wrong one).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
D1 hardware finding: the repeater picker was an unsorted, unsearchable dropdown, unusable with many repeater contacts. Replaced with a case-insensitive alpha-sorted, searchable list; discovered regions are alpha-sorted too. Widget test asserts sort order and search filtering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR Gemini review (Epic #815) flagged a scope leak: setChannelSendScope was outside the try, so a lost ack after the firmware applied the scope skipped the clear, and the next unscoped channel send would ride the stale scope. Moved the set inside the try so the finally always clears. Regression test asserts a clear is attempted when the set throws.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A firmware-gated channel overflow action opens a picker that discovers regions from a chosen repeater (reusing RegionDiscoveryService), lets the user select a region or None, and persists it via connector.setChannelRegionScope (C1 store). The dialog is decoupled (data + callbacks + injected service) for widget testing. l10n added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sendChannelMessage routes both the text and reaction sends through _sendChannelFrameScoped: when the channel has a region scope (C1 store), it sets the flood-scope key (CMD 54 sub-0, SHA256 of the region) before the send and clears to node default after. Fail-closed: a scoped channel on firmware without region support is blocked, never sent unscoped. Tested via a seam: unscoped passthrough, scoped set/send/clear with the right key, and the blocked case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ChannelRegionScopeStore persists a channel-index -> region-name map per device (first 10 hex of the device key, matching the other channel stores). set/clear/scopeFor, null = unscoped. Tests cover round-trip, per-device and per-channel isolation, persistence, and no-device-key guard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR Gemini review (Epic #814) flagged a use-after-dispose: if the screen pops while a discovery is in flight, the completing future would call notifyListeners on the disposed service and throw. Added a _disposed flag + _safeNotify guard + dispose override, with a regression test that disposes mid-discovery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A firmware-gated repeater-hub tile opens a Discover Regions screen that runs RegionDiscoveryService against the selected repeater and renders each state distinctly (loading / region list / no-regions / timeout / error, with retry). Shows region name + source repeater; the wire reply has no per-region SNR, so none is shown (the B2 spec's SNR is not available). Widget tests cover all four states via an injected fake discoverer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>