fix/1140-apple-app-names
main
dev
feat/1109-macos-signing
feat/2069-indicator-tags
fix/1128-channel-key-history
feat/1129-sponsor-prompt
fix/1118-factory-reset-delete-on-send
fix/1122-notify-logging
fix/1896-client-slot0-public
feat/1054-factory-reset
fix/1111-osm-identity
feat/523-land-indicators
feat/1108-macos-artifact
feat/1014-ios-app-review
feat/1013-ios-signed-lane
feat/1068-radio-channel-notify
docs/1091-epic-chain
docs/1053-play-automation
fix/1065-vercode-floors
feat/1012-ios-signing-toolchain
docs/958-ios-appstore-plan
fix/1033-scan-location-message
feat/693-offline-app-settings
fix/1009-release-linux-2404
release/1.7.0
epic/870-console-gating
feat/759-fake-radio-integration
feat/758-fake-radio-ci
feat/757-fake-radio-behaviors
feat/756-fake-radio-core
epic/870-remote-cli-permission
feat/842-sync-is-truth
feat/975-settings-restructure
feat/966-clear-stale-history
feat/965-stale-blob-sweep
docs/962-epic-chain
docs/962-reclamation-plan
fix/943-952-storage-migration
feat/940-isolation-integration
feat/939-scope-key-lifecycle
feat/938-blocked-per-radio
feat/937-storage-service-migration
feat/936-scoping-harness
docs/853-isolation-plan
fix/754-discovery-per-radio
feat/920-message-rows
chore/904-docs-proof
feat/919-blob-isolate
fix/927-ci-spike-flag
feat/917-storage-save-path
chore/904-docs-only-ci
docs/857-interop-plan
feat/844-per-radio-notify
fix/812-land-814-815-on-dev
epic/816-integration
epic/814-discover-regions
epic/813-region-protocol
epic/815-channel-scope
docs/812-region-scoping-plan
release/1.6.0
fix/793-log-composer-budget
feat/703-forced-resync
docs/755-plan-flash-fix
docs/755-fake-radio-plan
docs/750-contact-naming-plan
feat/702-lossless-serial
feat/668-contact-sync-guard
feat/610-contact-card-receive
fix/717-linux-web-mtu
feat/638-top-toast
fix/589-late-reply-log-only
fix/663-triage-internal-actors
fix/652-release-prerelease-flag
feat/619-contact-identity-uri
fix/581-ack-adoption
epic/568-export-import
fix/580-caplog-partial
release/1.5.0-beta.1
task/501-deviceui-logging
chore/542-assets2
chore/542-assets
docs/519-claudemd-web
release/1.4.0
feat/483-button-buzzer-ui
fix/497-mention-verbatim
fix/279-send-width
docs/478-docs-home-research
release/1.3.0
fix/443-battery-optimization-warning
fix/447-close-labeler
fix/445-windows-sqlite3-native-assets
feat/429-companion-sync
fix/395-dm-channel-send-wedge
feat/427-localize-log-share
feat/393-app-log-share
fix/share-icon-android
fix/389-back-nav
feat/385-storage-health
release/1.2.2
feat/367-store-consolidation
chore/365-cut-1.2.1
fix/363-pin-db-path
feat/351-contact-settings-menu
feat/349-window-geometry
feat/355-migration-merge
integration/290-306-335
release/1.1.2-rc.3-allplatforms
fix/309-path-len-decode
chore/298-path-logging
fix/285-ingest-timelog
fix/252-block-outgoing-dm
feat/64-observer-config
feat/batch1-ux
chore/offband-rebrand
v1.8.0
v1.7.0
v1.6.0
v1.5.0-beta.1
v1.4.0
v1.3.0
v1.2.2
v1.2.1
v1.2.0
v1.1.2-rc.3-allplatforms
v1.1.2-rc.3
v1.1.2-rc.2
v1.0.0
observer-g2-rc3
observer-g2-rc4
observer-g2-rc5
observer-g2-rc6
observer-g2-rc7
observer-g2-rc8
observer-g2-rc9
preview-1.8.0-1
v1.1.0-rc.1
v1.1.0-rc.2
v1.1.0-rc.3
v1.1.2-beta.1
v1.1.2-beta.2
v1.1.2-beta.3
v1.1.2-ble-test
v1.1.2-queuediag
v1.1.2-rc.1
v1.1.2-rc.1-b45-nearbyrep
v1.1.2-rc.1-b46-pathhash
v1.1.2-rc.1-b47-tracewidth
v1.1.2-rc.1-b48-repeatertrace
v1.1.2-rc.1-b49-maptrace
v1.1.2-rc.1-b50-tracefix
v1.1.2-rc.1-b51-originstrip
v1.1.2-rc.1-b52-channelqr
v1.1.2-rc.1-b53-viarepeater
v1.1.2-rc.1-b54-rxfed
v1.1.2-rc.1-b55-topology
v1.1.2-rc.1-b57-routeux
v1.1.2-rc.1-b58-toporeadout
v1.1.2-rc.3-b59-pathlen
${ noResults }
1337 Commits (054c7ddba269582d8aee4e55b6672bc68b61335f)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
054c7ddba2 |
docs(#976): record the approved tones and screen parity mockup
Owner-approved 2026-10-25 with four decisions: Screen and Battery under Node Settings, a separate play button, no way back to radio default, and the Menus and lists / Messages labels. Built against meshcore-firmware 3e788e2a. Covers #970, #971, #986, #987. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
317ce17437 |
feat(#981): channel tune codec over 0xC5 0x0B/0x0C
Codec and connector for per-channel tune (#971), gated on caps byte 2 0x10 per meshcore-firmware#1789. ChannelTune makes inherit (stored 0) and Silent (tune id 0, stored 1) distinct values with the byte never built by hand, because collapsing them is unrecoverable from the user's side. Per-channel state follows only the device's re-read reply, a refusal is attributed to the channel-tune surface, and a new radio clears the previous one's tunes. No UI yet: the picker waits for an owner-approved mockup, and for the tune catalogue (builtin ids and names), which is not on the wire. Agent: MistyLynx (session 6c0d5797) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
bf47a26155 |
feat(#978): split Button and buzzer into Node Settings and Message Settings
Button actions stay under Node Settings, shown only on caps byte 2 0x02. Buzzer and tones move to Message Settings as their own card under the phone-notifications card, shown only on 0x01. DeviceUiView now renders one part, requests only that part's state, and shows a refusal only on the screen it belongs to. Boards with a buzzer and no button exist (m5stack_unit_c6l, thinknode_m9, wio-tracker-l1-eink), which is why the homes are separate. Placement and entry gates only: the screen already showed each half from its own bit, and every wire write is unchanged. The Buzzer and tones subtitle says 'When this radio beeps' rather than the mockup's 'and which tone', because no tone picker exists until #971 and #970. Tests: all four button/buzzer combinations, and that each entry opens and requests only its own half. Full suite 1506 pass. Agent: MistyLynx (session 6c0d5797) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
4209b75284 |
feat(#969): GPS mode and Power-save interval over 0xC5
Codec for 0xC5 sub-codes 0x0D/0x0E, gated on caps byte 2 0x20, per the contract on meshcore-firmware#1789: the 14-byte state reply (resolved mode, raw stored byte incl. 0xFF, ps_ok, activity, 32-bit interval and wait), extended in place in offband_device_ui.dart and the existing 0xC5 dispatcher rather than a second codec. Caps bits 0x10 channel tune and 0x20 GPS mode added beside the others. On a 0x20 radio the Location GPS card is the approved one Off / On / Power save control, one status row naming backed off with its wait and last fix, and a sleep row only in Power save where 0 reads as the firmware default. Power save is offered only where ps_ok says the build can run it. Every value shown is the device's reply. Other radios keep the #977 on/off toggle unchanged. Safety: a mode change resends the radio's own interval, so setGpsMode refuses visibly until state has been read rather than send an interval it does not know. The 0x7F error frame names no sub-code, so a refusal is attributed to the outstanding write and each screen shows only its own. A confirmed mode keeps the legacy gps custom-var mirror, and so the 0xC1 poll, in step, but never invents the var. Tests: codec, connector (gate, bytes, attribution, mirror) and screen. Full suite 1499 pass. Agent: MistyLynx (session 6c0d5797) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
2a6d7383fc |
feat(#977): Location is a top-level settings category and the only home for GPS
New LocationView pane directly under Radio Settings: the GPS toggle and the 0xC1 GPS status, then Coordinates and Position reporting interval as rows that open their own editors. The GPS toggle and the Location row leave Node Settings, and the combined Location dialog is gone, so GPS appears in exactly one place. What the radio sees is unchanged: the same gps and gps_interval custom vars, the same 60 to 86399 s validation, the same error messages. One deliberate difference: coordinates and the interval now save independently. The old dialog returned early on an empty or invalid interval and silently skipped saving the coordinates too. Tests: settings_location_test pins placement from both sides and the writes. Two existing settings tests now scroll a category into view before tapping it; the lazily built category list put Debug past a 900 px viewport once Location was added. untranslated.json is a full regeneration and also picks up two block_noRadio keys dev was already missing. Agent: MistyLynx (session 6c0d5797) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
0d233216f0 |
docs(#976): correct the mockup's wire facts, design unchanged
The command is 0xC5 sub-codes 0x0D/0x0E with caps byte 2 0x20, not a new 0xC7. Power-save availability comes from ps_ok in every GPS reply. The radio raises a sleep interval of 1 to 299 s to 300. The buzzer-only examples are now boards that actually lack a button; the T1000-E has both. Facts confirmed by AmberForge on Agent Mail 2026-10-24 and verified on firmware-base. Agent: MistyLynx (session 6c0d5797) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
6f50a2aff1 |
docs(#976): record the approved Location and settings mockups
Owner-approved 2026-10-23. Six numbered state cards drawn against the real SettingsShell widgets, with behavior bullets as the build contract and a rationale section, following the firmware GUI mockup convention. Self-contained: inline SVG icons, system fonts, no network references. Covers GPS off, on, Power save backed off, a build without Power save, the Settings menu before and after, and the button and buzzer split. Serves #969 as well as epic #975. Agent: MistyLynx (session 6c0d5797) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
bf392162ab |
fix(#989): stop the use-after-free crash when the Windows app closes
Crash A of #984. windowManager.destroy() in window_manager 0.5.2 only posts WM_QUIT, so WM_DESTROY never fires and OnDestroy never nulls the controller. The empty ~FlutterWindow then let ~unique_ptr delete the controller without nulling it first; a message arriving mid-teardown (accessibility, text input) went through MessageHandler into the half-destroyed view and FlutterWindowsView::GetEngine read freed memory. Symbolized from crash dumps at flutter_windows.dll+0x1d7b0. - ~FlutterWindow moves the controller out before destroying it, so the MessageHandler guard holds on every quit path. - main.cpp scopes the window so it is destroyed before CoUninitialize. Crash B (BLE plugin unhandled C++ exception) and the link drops stay on #984. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
5b061924c7 |
feat(#966): mock-ups, a confirmation that states the scope, and the eligibility bug it found
The owner asked why there were no UI mock-ups to review. There was no good answer: this Epic's card was built and installed on both his machines without him ever seeing the design, and the plan had no step requiring otherwise. So the plan now has B0: mock-ups first, owner-approved before any of it is built, as a committed HTML file beside the plan in the format his firmware initiative already uses. epic-b-storage-mockups.html is that file: eleven numbered variants with real Material 3 tokens, a 360-wide pane, example values from his own install, and the behaviour to build as bullets under each. Owner-approved 2026-10-23. The confirmation (B1a, variants 8 to 10). It states how many copies, roughly how much space, how many messages are NOT affected, and that it cannot be undone, with the count on the action button so the scope is the last thing read. Cancel, a tap outside and back all decline. Nothing eligible opens no dialog at all, because a dialog that only says zero is not worth opening. That needed a preview pass: verify every eligible key and total it without deleting, so the numbers are real rather than estimated. Verification now runs twice for a confirmed clear, once to preview and once under the key lock. That is the cost of saying what will go before it goes. approximateBytes is named for what it is: the sum of blob lengths, not what VACUUM returns, because page rounding moves the real figure; the dialog says "about" and the result reports what actually changed. Writing the preview found a real bug, and it was about to bite him. The "skip anything stamped this pass" guard exists so installing an update never deletes a blob on sight, and it was being applied to the button as well. Every install that migrated before #962 has an empty __msgrows_migrated_at, so the first press stamped everything and cleared NOTHING. His install is exactly that: 47 migrated keys, none stamped. He would have pressed the button on a 70.9 MB database with 16.6 MB of reclaimable blobs and been told "nothing to clear". Fixed as eligibleByAge, one static rule both preview and run share, so the dialog cannot promise what the sweep will not do. An explicit press includes newly stamped keys; the automatic sweep still refuses them. Three tests on the rule directly, plus one proving the automatic path refuses while the press clears. Label stays "Clear Stale History Now" rather than "Compact", owner's call and the better argument: the name generalises to clearing stale history for other reasons later, such as #963's retention limit. 14 widget tests and 34 storage tests. Full suite 1,502 pass, 1 skipped. analyze clean, format clean. Task: meshcore-open-gan Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
3168c413c9 |
feat(#966): a durable audit of what the sweep deleted
The owner asked whether the delete and compact are logged or silent. Checked rather than assumed, and the answer was bad on two counts: - A delete logged only a COUNT. "Cleared 40" names nothing, so there was no record of WHICH keys were reclaimed. Refusals were already logged properly, with the key and up to three missing records; deletions were not. - All of it goes through appLogger, which is gated on appDebugLogEnabled. That is off by default and off on his install, so on a normal install an irreversible delete left no trail at all. Both fixed. The summary now names the cleared and kept keys, and the sweep also writes a durable entry into the database itself, under __msgrows_reclaim_audit: timestamp, the cleared keys in full, the refused keys, and the byte counts either side. In the database because that survives restarts and the logging toggle, and can be read back without the app running, which is also how it gets verified on hardware. Key lists are stored in full. Truncating them would recreate the "Cleared 40" problem the entry exists to solve. Capped at the last 5 sweeps instead, which is generous given a key is cleared once and then has no blob to consider again, so this is close to a once-per-install operation. A failure to write the audit is logged and tolerated rather than failing the sweep: the deletes already happened and were each verified, so losing the record is worse than nothing but far better than reporting a correct sweep as broken. An unreadable audit likewise starts a fresh list and is logged, never silently replaced. scoping_inventory gained the key, globalOnPurpose: one sweep spans every radio and the entries name store keys that already carry the radio. Six tests: a sweep records which keys it deleted; the entry survives a fresh store object over the same database, so it is really on disk; refusals are recorded too; a sweep that did nothing writes no entry; the cap drops the oldest and keeps the newest; and an unreadable audit does not stop a sweep. Lands on Epic B's branch rather than Epic A's, where the sweep lives, to avoid restacking B over a changed A for a change that reaches dev through both either way. Origin is #965's engine. Full suite 1,489 pass, 1 skipped. analyze clean, format clean. Task: meshcore-open-gan Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
cc2fb23b2e |
feat(#966): Clear Stale History Now, with refusals reported
Epic B of #962. The manual trigger for Epic A's sweep, so a user does not wait 30 days. It passes olderThan: Duration.zero, which skips the WAIT and not the safety: the per-key verification inside reclaimVerifiedHistoryBlob is unchanged, proved by a test that Duration.zero still refuses a key whose rows cannot be verified. Its own widget rather than another card in app_settings_view.dart, which is already past a thousand lines, and so the tests can drive it without standing up the whole settings screen. Stateful against the repo's usual StatelessWidget-plus-Consumer habit, because the running flag and the last result are local to this card and belong to no service. The sweep is INJECTED, and that is a finding rather than a convenience. The first version of the widget test called the real sweep and hung past ten minutes instead of failing: the sweep uses compute, and an isolate deadlocks pumpAndSettle inside flutter_test's fake-async zone. A widget test should test the widget. So the card takes the sweep and the size read as parameters, the nine widget tests feed outcomes in directly, and the two new tests in stale_history_sweep_test.dart keep the injected semantic honest under plain test() where isolates work. Owner decision 2026-10-22, and the reason this Epic exists: the button REPORTS keys it refused, it does not only log them. "Cleared 1 old copies, 16.0 MB returned" and "Kept 1 that could not be verified" are both shown, including when a refusal is the reason nothing was cleared, which is the most important case to surface rather than the least. The automatic sweep stays log-only. Two Gemini findings, both fixed: - A fast double tap ran two sweeps. The disabled button is not enough on its own, because setState only SCHEDULES a rebuild, so two taps inside one frame both see a live onPressed. _clear now returns early on _running, and a test taps twice in one frame and asserts the sweep ran once. Two concurrent sweeps would have meant two concurrent VACUUMs. - A throw after real deletes was reported as a total failure. The honest fix was in the sweep, not the string: a successful sweep must not become an error because the closing size read threw, so that read now falls back to bytesBefore, which understates the reclaimed figure rather than inventing one. The failure string no longer claims nothing happened either. Strings are English-only, as everywhere else here: gen-l10n falls back to the template locale, and untranslated.json records the 9 keys across 17 locales, which is what that tracked file is for. 29 tests in the two files, full suite 1,483 pass, 1 skipped. analyze clean, format clean. Task: meshcore-open-gan Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
84c2dbd9da |
fix(#965): act on the Gemini gate findings
Three findings, all addressed. One was a real data-loss hole. 1. CRITICAL, fixed. missingFromRows trusted the converter's own output as the measure of the converter. A callback that silently dropped a record, rather than throwing, would have had every record it DID produce found present, returned "nothing missing", and the blob holding the only copy of the dropped one would have been deleted. The migration gate has always guarded this with an independent countJsonArrayElements in a separate compute; the verify path I added did not, which left the two halves of the same rule unequal. It now does the same cross-check and refuses as unverifiable on a mismatch. New test: a converter that drops its last row is refused with missing == null, not missing == []. 2. Measured, not redesigned. VACUUM takes an exclusive lock, and the review called it a UI freeze. It is not: drift_flutter runs SQLite in a spawned isolate (_isolateEntrypoint -> DriftIsolate.inCurrent, verified in the package), so database access queues behind it while the UI thread stays free. That is a stall on history-backed reads, not an ANR, which is the distinction that matters for an app whose storage work began as an ANR investigation. It is also close to once per install, since a cleared key has no blob to consider again. The duration is now logged, because 0.6 s on the owner's 70.9 MiB database is a number worth watching in the field rather than assuming. 3. Fixed, and the review was right about why. The non-history-key test asserted only that the isHistoryKey helper returns false, which would still pass if run() stopped calling it: the loop visits keys that HAVE rows, and contacts and friends normally have none, so they were never reachable anyway. Those keys now get a planted row and a 99-day-old timestamp, so the prefix guard inside run() is the only thing standing between them and a delete. Also carried over from the previous round: the check-then-delete window is closed by BlobStore.reclaimVerifiedHistoryBlob, which holds the per-key lock across both the verify and the delete and so also serialises the sweep against a migration or a blob merge on the same key. Two tests cover it, including that a second call on a blobless key reports neither a delete nor a problem, which is what makes a repeat sweep silent. On the owner's database 7 of 47 aged keys are in that state, having migrated through the empty-blob path. 18 tests in the file, full suite 1,472 pass, 1 skipped. Re-verified against a copy of the owner's real database after the changes: blob keys: 477, migrated history keys aged: 47 cleared 40, refused 0, 17362944 bytes returned (16.56 MiB) kept keys missing: 0, byte-changed: 0 integrity_check: ok, rows 29950 -> 29950 Task: meshcore-open-5vt Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
1b38c674e4 |
feat(#965): per-key migration timestamps and the stale-blob sweep
Epic A of #962. The engine only; the manual action is #966. A1 wrote the diagnosis as failing tests first, and they were red for the right reason: no sweep existed and six BlobStore methods did not. Also lands a control test asserting that DELETE alone does not shrink the database, which passes today and must keep passing, since it is the entire reason a VACUUM step exists. A2, the timestamps. A second blob, __msgrows_migrated_at, keyed by store key. Deliberately NOT a reshape of __msgrows_migrated: that marker is read on every save and every load through a cache, so changing its shape would put a migration of its own on the hottest storage path. Owner decision 2026-10-22. An install that migrated before this ships has no timestamp, so backfillMigratedAt stamps it on FIRST SIGHT and the sweep skips anything it stamped this pass: updating the app can never delete a blob on the spot, and every key gets the full 30 days measured from when we first knew about it. A3, the sweep. Eligibility is age plus a fresh verification, never the marker alone, which is the difference between "we recorded that this migrated" and "we just confirmed it". missingFromRows returns null for "could not tell" and an empty list for "verified nothing missing", and those are deliberately different values: collapsing them is how the only copy of an unreadable history gets deleted. A4, one VACUUM, only when something was deleted. A failure there is logged and tolerated: the deletes already happened and were each verified, so it costs space and not data, and the next sweep retries it. The comparison rule now lives in ONE place, _absentFrom, used by both the migration gate and the sweep. The gate still decodes its own blob, because a second compute over multiple megabytes on the migration path would cost more than the duplication saves, so the rule is shared even though the decode is not. Wired into main.dart's existing post-frame callback, beside the #906 exit-info collector, for the same reason: storage hygiene must never be on the path to a usable screen. The catchError sits at that fire-and-forget call site rather than inside run(), so #966's button still gets a failure it can report. scoping_inventory gained the new key, because the #853 gate caught it exactly as designed. Its verdict is globalOnPurpose for the same reason as the marker: it is keyed BY store keys that already carry the radio. 15 tests, the plan's whole acceptance bar: the 29-day and 30-day boundaries; one missing row refuses AND the same key clears once its rows are complete, which is what proves the gate discriminates rather than never deleting; an undecodable blob refuses; an unstamped key is stamped not deleted; an unmigrated key is untouched; contacts, discovered_contacts and path_history_ are asserted non-candidates BY NAME; a DM key sweeps through the DM converter; and every kept blob is byte-identical across the sweep and VACUUM with the row count unchanged. A5, verification against a copy of the owner's real database through the production sweep: blob keys: 470, migrated history keys aged: 47 cleared 40, refused 0, 17367040 bytes returned (16.56 MiB) kept keys missing: 0, byte-changed: 0 integrity_check: ok, rows 29949 -> 29949 47 aged and 40 cleared because the other 7 migrated through the empty-blob path and never had a blob to reclaim. Full suite 1,469 pass, 1 skipped, up from 1,454. analyze clean, format clean. Task: meshcore-open-5vt Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
75e082dbd5 |
docs(#962): fill epic_chain with the three Epic issues
965 Epic A the timestamp and sweep, 966 Epic B the manual action, 967 Epic C Integration Testing. All three are sub-issues of #962, with Citadel tasks meshcore-open-5vt, -gan and -2jd chained A to B to C. This is a follow-up only because of a sequencing mistake on my part: the plan merged with epic_chain empty, and the grant validator requires a non-empty list while the mint path indexes its first and last entries, so the plan as merged could not have minted anything. The Epic issues need to exist before a plan PR merges, which is how #853 did it. Noted in section 5 so the next plan does not repeat it. No other term changes: budget 3, owner verification, 168-hour expiry, chain true, no flash. Task: meshcore-open-8xj Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
2b29f1207e |
docs(#962): record the owner's four decisions and resolve the placeholders
Owner, 2026-10-22: "X = 30, sweep once per launch, timestamps in separate blob, button reports refusals, agreed." All four as recommended. Section 9 becomes a Decisions table carrying the reason for each, so the trade-offs are on the record rather than only the outcome. Every X placeholder is now the literal 30 days: at a glance, scope, A3, the can't-tell row for a key with no timestamp, the acceptance bar and done-when. A plan that goes to build with placeholders in it invites someone to pick a number themselves. Three things sharpened while propagating them: - A3 states the once-per-launch wiring, since the trigger is a real decision and not an implementation detail. - B1 states that the manual action REPORTS refused keys in its result, not just in the log, which is what decision 4 actually asks for. It previously only said it shared the sweep's refusal path. - The acceptance bar tests the boundary at 29 days, not just the middle of each range. Noted that 30 days is a named constant, so changing it later is one edit and the tests can set it directly rather than manipulating clocks. Task: meshcore-open-8xj Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
e068880294 |
docs(#962): pin that VACUUM touches nothing the sweep kept
The owner asked whether VACUUM would wipe out non-migrated blobs. It cannot:
it rebuilds the file to reclaim pages the deletes already freed and never
removes a row that still exists. Measured on a copy rather than asserted.
deleted on purpose : 40 migrated history blobs
kept : 291 keys, of which 98 UNMIGRATED history
missing after VACUUM : 0
byte-changed after VACUUM : 0
unexpectedly present : 0
message rows : 28,389 -> 28,389
file : 74,350,592 -> 54,927,360
integrity_check : ok
Every kept blob was hashed before and after and came through identical.
The consequence is worth more than the reassurance, so it is now diagnosis
row 11: ALL the risk is in the DELETE selection and none of it is in VACUUM.
VACUUM makes a wrong selection permanent but never selects anything itself,
which is why the safety budget sits on the per-key re-verify and the
key-pattern restriction.
Added to the acceptance bar as a test: every kept blob byte-identical after
VACUUM, unmigrated history keys asserted present by name, row count unchanged.
Task: meshcore-open-8xj
Agent Mail: TealGlen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
3 weeks ago |
|
|
f6b5817c11 |
docs(#962): plan of record for reclaiming the stale post-migration blob
The #917 plan deferred this as a separate owner-gated step. The owner opened that gate on 2026-10-22 and specified the design: a per-key time marker, an automatic clear X time after a key migrates, and a manual "Clear Stale History Now" action. Ten diagnosis rows, each measured rather than asserted. The two that shape the design: - DELETE frees NO disk space. 74,350,592 bytes before deleting all 40 migrated blobs on a copy of the owner's database, 74,350,592 after. VACUUM is what shrinks the file: 54,919,168 bytes, -18.5 MiB in 0.6s, integrity ok, all 28,386 rows intact. - Migration is lazy and per key. His install had 40 keys migrated and 98 not, because those radios had not been reconnected, so an install-wide clock would delete nothing for a long time and then a lot at once. Also records what the retained blob does and does not protect, because "keeping a backup" is the wrong mental model: it is a frozen snapshot of the pre-migration history, and everything that arrived since has been single-copy from the instant it landed, blob or no blob. Three Epics, merge budget 3, owner verification per Epic because this deletes data. Five can't-tell branches, four of which stop; the one that proceeds is a failed VACUUM, which affects file size only and follows deletes that were each individually verified. Four choices for the owner, with recommendations: X = 30 days, sweep once per launch, timestamps in a separate blob rather than reshaping the hot marker, and the manual action reports keys it refused rather than only logging them. epic_chain is deliberately empty: the Epic issues do not exist until this plan merges, and it is filled in before the grant is minted. Task: meshcore-open-8xj Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
5f79c6e1db |
fix(#943,#952): act on the Gemini gate findings
Two of five findings were real.
Log the downgrade refusal BEFORE the path lookup and the throw. onUpgrade runs
inside drift's beforeOpen, so it fires on whichever isolate issued the first
query, and whoever catches the exception decides what the user sees. A
downgrade that only stopped background work, with nothing in the log, would be
the quiet failure this branch exists to replace. The log line now lands first,
so the record exists however the exception is handled.
Strengthen the recovery bench. It asserted row COUNTS, so a converter
producing the right number of rows with wrong contents would have passed.
It now also checks that every identified blob record has a row carrying its
identity, and that no payload is empty, both derived from the raw blob rather
than from the converter under test so the check is not circular. Records with
no messageId are not identity-checked, because reproducing the `x:` fallback
key here would make it circular again; the count assertion still covers them.
Verified on a copy of the owner's database with the trap reconstructed (the
6,308-message channel removed from the marker plus one planted row the blob
does not contain): the key migrates, and all 6,308 identities are present.
Then verified the new assertion can FAIL, by planting an empty payload the
migration does not overwrite:
Expected: empty
Actual: WhereIterable<String>:['{}']
Three findings were rejected, with reasons in the PR body: the duplicate
merge-key collapse is pre-existing and unchanged by this diff, the claimed
migration/blob-save race cannot occur because of the re-check inside the
per-key lock, and the path_provider call is bounded and already proven.
Task: meshcore-open-lkp
Agent Mail: TealGlen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
3 weeks ago |
|
|
e4ee26b089 |
test(#943): derive the schema numbers, and stop the cleanup masking failures
Three things that would have rotted: - The downgrade tests hard-coded 3 as "a future schema". The day the real schema becomes 3 they would still pass while testing nothing, which is exactly when they are needed. Both now derive from this build's own schemaVersion. The message-rendering test deliberately keeps unrelated fixed numbers (7 and 6), so a wrong substitution shows up. - Reading schemaVersion left an OffbandDatabase open for the whole file, and drift warns when two instances of one class are live. The probe moved into setUpAll and is closed immediately, because warnings in test output are how real ones get missed. - The temp-directory cleanup threw a PathAccessException when the test failed, since a failure leaves the database open and Windows keeps the file locked. That replaced the actual failure with a cleanup error. Now swallowed. Verified both downgrade tests fail when the `from > to` throw is disabled, and that the three upgrade-direction guards stay green, so the group is pinned to the behaviour it claims and not to the code's shape. Task: meshcore-open-3e1 Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
e692562765 |
test(#952): prove the stuck keys recover against a real database
The unit tests pin the invariant on fixtures. This runs the real migration
code over a copy of an actual database and finds the stuck keys by shape,
without using the code under test: a key that has rows and is absent from the
migrated marker.
On the owner's install, all 5 such keys migrate, 0 still refused:
keys with rows: 41, marked migrated: 36, rows but unmarked: 5
MIGRATED blob=237 rows 238 -> 238 ...psk_43636021
MIGRATED blob=5646 rows 5648 -> 5649 ...psk_8b3387e9
MIGRATED blob=1578 rows 1585 -> 1585 ...psk_9cd8fcf2
MIGRATED blob=6308 rows 6313 -> 6313 ...psk_e3c26491
MIGRATED blob=16 rows 16 -> 16 messages_d549bbfbd060dae1...
psk_8b3387e9 is the one worth reading twice: 5,648 rows became 5,649. That key
held 3 rows the blob does not have AND one blob record that had never reached
a row. The old gate saw unequal totals and refused, so that record stayed
unreachable from rows indefinitely; the superset check inserts it and then
verifies it is there.
Asserts no key loses records, every migrated key ends up marked, and every
retained blob is still present, so the run is non-destructive beyond the copy
it works on. Reports and skips when BENCH_DB is absent, and says explicitly
that a database with no stuck keys is not a pass.
Task: meshcore-open-lkp
Agent Mail: TealGlen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
3 weeks ago |
|
|
b08b42bff8 |
fix(#943): refuse a newer schema loudly instead of rewriting the version
Owner decision, 2026-10-22: downgrades stay unsupported, and must fail deliberately rather than silently. Before this, onUpgrade guarded on `from < 2`, so a database from a FUTURE schema fell straight through it. The open succeeded, and drift then stamped user_version back down to this build's 2, because it writes schemaVersion whenever the versions differed. The database's only record of having been on a newer build was destroyed, and a later run of that newer build would re-run its own upgrade against tables already in their new shape. onUpgrade now throws OffbandSchemaDowngrade when `from > to`, naming both schemas, saying the upgrade is one-way, and giving the database path when it can be resolved (path_provider has no web implementation, so the path is best-effort and the message stands without it). Not made to work, deliberately. Once a key is on rows its blob is frozen, so an older build would read stale history and write into the blob while this one reads rows, orphaning everything it received. Supporting a downgrade would mean dual-writing the blob on every save, which is the cost #917 exists to remove. The two tests that documented the old silent behaviour now assert the refusal, including that user_version is left at 3 on a file-backed database, which is the whole point of throwing. Three more guard the upgrade direction: an equal version takes neither branch, a fresh database still lands on 2, and a real 1 to 2 upgrade still carries its blobs across, since that is the path every existing install takes. Release-note disclosure and rollout staging are tracked separately. Task: meshcore-open-3e1 Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
eef192ea7f |
fix(#952): the migration gate asserts a superset, not equal totals
Four of the owner's store keys could never migrate to rows. Each held rows whose merge_key is absent from its blob (1, 3, 7 and 5 of them), and the readback gate compared the key's TOTAL row count against the number of distinct records in the blob. Those two numbers can never be brought level again: nothing removes the extra rows, and the frozen blob can never come to contain them. Every attempt refused, for good. His two largest channels were among them, 6,313 and 5,648 messages, so they kept re-encoding 3.77 MB and 3.55 MB of history on every save. That is the cost #920 removed and they were not getting it. No data loss: the blob stays complete and authoritative for a refused key. The gate asserted the wrong invariant. What it exists to catch is a record from the blob failing to reach a row, which is a SUBSET question. Equality became wrong the moment rows could legitimately hold messages the frozen blob does not, which is true for every migrated key the instant a new message arrives. - messageMergeKeys(storeKey) reads back the stored identities, and the gate now requires every deduped blob key to be among them. Rows beyond that are logged and accepted rather than treated as corruption. - The refusal log names how many records are missing and samples three, which is more use than the two totals it printed before. - The four keys need no healing migration: they migrate normally at next access, pinned by "a key stuck under the old gate recovers on next access". The step can still fail, and both halves of the loop are proved: a BlobStore whose insert silently drops a row is refused and stays on the blob, and a lossy converter is still refused before anything is written. Renamed the test file, since it now guards the gate rather than describing the trap it was written to demonstrate. Task: meshcore-open-lkp Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
6abd5af8d9 |
docs(#943): the first correction gave the wrong reason, revise it
|
3 weeks ago |
|
|
e2b8970f1f |
fix(#943): establish what governs a downgrade, and correct the claim in code
#943 says drift "refuses to open a database whose user_version is newer than the binary's schemaVersion". That is wrong, and the real mechanism points somewhere else. drift has no newer-schema refusal. It compares the stored user_version against schemaVersion for INEQUALITY and routes both directions into onUpgrade ("Schema version upgrades and downgrades will both be run here"). The pre-#917 build declared schema 1 and no migration getter at all, so it got drift's default onUpgrade, which throws. That is why the owner's old install would not start: not a version check, but a missing strategy, so it never reached the blobs it would have found. The consequence matters for the decision. This build does NOT throw on a downgrade: the guard is `from < 2`, so a database from a future schema opens silently, and drift then stamps user_version back down to 2, erasing the only record that it has been through a newer build. The next schema bump inherits a silent failure rather than #943's loud one. - schema_downgrade_test.dart, six tests: a newer version reports as hadUpgrade rather than being refused; a bare strategy throws (the #943 failure); this build opens a schema-3 database without complaint; and it rewrites the version backwards. Two more pin that an equal version takes neither branch and that a fresh database still lands on 2, so the downgrade cases are genuinely testing the downgrade path. - Corrects the stale claim in offband_database.dart, which still told readers a downgrade finds its history. The merged plan was corrected in 4b8f5731; this is the same correction in the code it describes. Also lands migration_refusal_trap_test.dart for #952, found while gathering this evidence: four of the owner's store keys hold rows the blob does not, so the readback gate's equality check refuses them on every attempt, for good. Five tests, including the clean-key control and a check that no message is lost. Fix deliberately not included; #952 carries the decision. The three decisions in #943 are untouched and remain the owner's. Task: meshcore-open-3e1 Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
4b8f57312c |
docs(#943): correct the downgrade claim in the #917 plan of record
The plan's Epic B outcome said a downgrade to an older build "still finds its history where it looks for it". True of the data, false in practice, and the owner hit it: drift refuses to open a database whose user_version is newer than the binary's schemaVersion, so a pre-#917 build cannot open a migrated database at all. It never reaches the blobs it would have read. Correcting it rather than leaving a wrong statement in a merged plan of record, because the consequence is a release one: once a user launches a schema-2 build they cannot go back to an earlier release, so a Play rollback or a halted staged rollout does not help anyone who already launched it. The real fallback is an older build PLUS a pre-migration database copy, which loses everything received since. #943 carries the open decisions. This commit only fixes the claim. Citadel: meshcore-open-gq7 Refs #943, #917, #920 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
6945de1723 |
epic(#940): the full isolation matrix, across a switch and a restart
Feature #853's Integration Testing Epic. Epic A declared a verdict per surface and Epics B, C and D closed the holes; this proves the whole thing against the bar #853 actually set: "the datum is isolated per radio, and a second radio does not inherit the first's copy, across a radio switch AND an app restart" Eleven tests. Nine surfaces each go through the same three-step shape rather than a bespoke assertion: radio A writes, radio B must see nothing, then back to A and the datum is still there. That last step matters, because isolation is not deletion and a fix that simply wiped on switch would pass a two-step test. Every read builds a NEW store object, which is what a restart does to storage, so each case covers the switch and the restart at once instead of needing two tests per surface. Covered: channel messages, direct messages, contacts, discovered contacts (#754), channels, channel order, unread counts, path history (#854), delivery observations (#856). Plus Epic D's fail-closed property across several stores at once, and #853's own closure condition asserted as a test: no surface unclassified, no hole remaining, every verdict carrying a reason. **Proven to catch a regression, not assumed to.** I dropped the radio from ChannelOrderStore's key and the matrix failed, naming the surface: channel_order_: radio B MUST NOT inherit radio A's datum Expected: false Actual: <true> Then reverted, tree clean. Three harnesses in this session's earlier work reported success while measuring nothing, so a gate nobody has watched fail is not a gate. Two API guesses were wrong and corrected by reading the source rather than by trial: Contact's constructor takes name/pathLength/path/lastSeen, not the advName/outPath/lastAdvert I assumed, and UnreadStore's methods are save/loadContactUnreadCount with a DEBOUNCED write, so the unread case flushes before reading or it would have tested the in-memory pending map instead of storage. Suite 1,436 pass, analyze clean, format clean. Outstanding: E2, two real radios on the owner's install, which is his step and the last thing between this Feature and closure. Citadel: meshcore-open-7g6 Refs #940, #853, #754, #854, #856, #158, #507 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
6574ecfa90 |
epic(#939): close the stale-read window, and a verdict on #190
Epic D of Feature #853. A different defect class from Epic B: these stores ARE correctly per-radio and bled anyway, through timing. **D2, #158: the window was an unexpected disconnect.** A manual `disconnect()` clears `_selfPublicKey`; `_handleDisconnection()` did not. So after a drop, every per-radio store, and the one `channels_screen` builds from `selfPublicKeyHex`, still held the previous radio's prefix. Connect a DIFFERENT radio and any read before its SELF_INFO landed served the old radio's history. That matches #158 being seen on desktop and not Android: it is a race, not a logic error. `_clearStorageScopeKeys()` now runs on an unexpected drop. It clears `_selfPublicKey` as well as the eleven connector-owned stores and StorageService, because `channels_screen` does not share those instances and derives its key from the getter, so clearing only the stores would have left that path reading stale. Verified before relying on it: every one of the thirteen per-radio stores guards on an empty key, in BOTH directions. I checked rather than assumed, because if any had used a bare prefix instead, clearing the key would have created a NEW unscoped global key, which is the exact bug class this Feature exists to remove. The names and deviceId stay, so the UI can still say which radio it is reconnecting to; only the data-scoping identity goes. **D1, #190: its named root cause is addressed, proven not argued.** #190 blamed slot-index keying with no PSK binding. Two mechanisms landed since: PSK-identity keying (#194) and the reuse-clear in `setChannel` (#193). Rather than assert that, there are tests reproducing #190's own scenario, a slot reassigned to a different channel: - keyed by PSK, the newly added channel reads a different key and sees nothing of the old occupant, which is #190's reproduction - the old occupant keeps its own history, because this is isolation by identity rather than deletion - the index fallback still works and still warns, pinning the remaining surface What remains of #190 is the index fallback firing before the channel list has loaded, which is #333 and a timing problem, so it belongs to this Epic rather than to #190. Recommendation for the owner: close #190 as superseded by #193, #194 and this Epic. Not closing it myself; it is his call and D1 was scoped as a verdict with evidence, not a closure. Suite 1,425 pass, analyze clean, format clean. Citadel: meshcore-open-pdx, meshcore-open-cwd Refs #939, #158, #190, #333, #853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
afbab311e6 |
fix(#946): classify stock 1.18.0 by frame shape, not version suffix
Real stock 1.18.0 reports its version as "v1.18.0-81dbae5" (semver + git-describe hash), which classifyVersionString reads as the Offband dual form. On a stock-shape (82-byte) frame the classifier treated that as an inverse conflict and failed closed to stockLegacy, so the Device CLI console (#883/#884) was hidden on every real stock 1.18.0 device. Found by E2.2 bench verification (#869) against a live device. An 82-byte device-info frame is definitively stock: the Offband fork always appends at least one caps byte (>=83 bytes). So a stock-shape frame is now classified by ver code alone, regardless of the version string. The fork-shape branch is unchanged - it still requires dual-version corroboration before emitting fork frames, the direction where being wrong is dangerous. Regression test uses the real hardware string v1.18.0-81dbae5. Full suite green (1394); Gemini 2.5-pro review found no defects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
3 weeks ago |
|
|
a21ff98c6c |
epic(#938): Blocked pane says "no radio" instead of "no blocked users"
Epic C of Feature #853, carrying #507. Much smaller than the plan assumed, because `BlockedView` already existed and was already wired into Settings. What it did not have was the distinction #507 asked for, or any tests. **The gap.** With no radio connected the pane rendered "No blocked users", which is indistinguishable from a connected radio with an empty list. Since #471 made blocks per radio, that wording invited exactly the wrong conclusion: that the blocks had been lost. Now a disconnected pane says "No radio connected" and explains why ("Blocks belong to the radio that holds them"), because the reason is what stops it reading as data loss. English-only string additions per the repo convention; `untranslated.json` tracks the other 17 locales. **Tests: 5, and the first one has teeth.** I proved it by disabling the new branch and watching it fail: Expected: exactly one matching candidate Actual: Found 0 widgets with text "No radio connected" Then reverted. A test nobody has watched fail is not evidence, and this session has already produced three harnesses that reported success while measuring nothing. Covered, all through the widget rather than the service: - no radio says so, and gives the reason - a connected radio with nothing blocked says THAT - a block made on radio A is absent on radio B - switching back restores A's list, because isolation is not deletion - an unblock survives a switch and back, which is #507's clear-sticks case and the re-clear that #471 was about #507 also lists "migration drops the global keys". That already exists: `BlockService.loadForDevice` calls `_store.dropLegacyGlobal()` on every device load. Noted rather than re-implemented. Suite 1,419 pass, analyze clean, format clean. Still outstanding for this Epic: the T1000e hardware check #507 asks for, which is an owner step and the only part I cannot do. Citadel: meshcore-open-uu9, meshcore-open-9ft Refs #938, #507, #471, #853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
c4e28b653d |
task(#937): B4 Epic B verification, rehearsed on a real prefs file
Rehearsed the legacy purge against a copy of the owner's live prefs, 2,149 keys and 4,277,260 bytes: purged 2,095 path-history keys, global observation list: yes legacy keys left: 0 prefs keys remaining: 53 this radio's scoped history: intact That last line is the point of the rehearsal. The purge removes the unscoped legacy keys and leaves the scoped history alone, which is only safe because the two live in different backends: new keys in drift, legacy in prefs. **The harness reports its own timings as NOT the real cost, loudly.** It measured a 4 ms worst UI block, and that number is worthless: the SharedPreferences test mock is an in-memory map and does not do the whole-file re-encode and rewrite every real `remove` performs on Windows. A time threshold here would pass no matter how slow production was, so there is deliberately no time assertion. The real figures come from simulating the plugin's actual behaviour against the same file: 12.7 s total, mean 6.1 ms, 32 ms for the first removal falling under 1 ms by the last as the file collapses 4.28 MB to 45 KB. That is why the purge yields every 20 removals. Flagging it rather than quietly reporting 4 ms, because a harness that measures the mock instead of the mechanism is the third false-green this session would have produced: the #919 heartbeat that read 0 ms, the #920 readback gate that compared a converter to itself, and this. **The 45 KB endpoint also means #923 is largely neutralised in practice.** Every remaining prefs write drops from ~32 ms to well under 1 ms, because 86% of the file is gone. The mechanism is untouched and #923 stays open, as the plan says, but its blast radius is now small. **Suite:** 1,414 pass, analyze clean, format clean. Epic B is complete: #855 deleted, #854 and #856 scoped per radio, #924 resolved, and the inventory's known-holes set is empty. Citadel: meshcore-open-sgu Refs #937, #854, #855, #856, #923, #924, #853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
a475cc21b4 |
task(#937): B2+B3 path history and delivery observations per radio
Both scoped by the device-key prefix, and the legacy untagged data deleted per the owner's decision 1 rather than orphaned. **#854 path history: per radio AND out of SharedPreferences.** Two changes in one migration, because doing them separately means two migrations over the same data. The key was prefix + contact, so a route measured by a 1 W handheld was applied to a repeater and the reverse. It now lives in drift as prefix + device key + contact. The move out of prefs is not gold-plating: that one store held 2,092 keys and 86% of a 4.28 MB file, and every prefs write on Windows re-encodes the whole file (#923). This resolves #924 and makes every unrelated settings write cheaper in proportion. **#856 delivery observations: per radio, staying in prefs.** A repeater's 45-second deliveries were training the ACK-timeout model a 1 W handheld then used. One small key, 3,091 bytes and 14 entries on the owner's install, so unlike path history there is no cost argument for moving it and a move would be churn. **The legacy purge is chunked, and that is measured rather than guessed.** Each prefs `remove` re-encodes and rewrites the whole file, so I measured the real sequence against the owner's live prefs before designing it: 2,092 keys, 12.7 s of total work, mean 6.1 ms, but 32 ms for the first removal and under 1 ms by the last, because the file collapses from 4.28 MB to 45 KB as it goes. I had assumed ~57 s and was wrong; it is tractable. One unbroken pass would still block for 12.7 s, well past the 5 s watchdog, which is the exact failure #917 just fixed. So it yields every 20 removals. It runs after the startup loads so it never delays first paint, and it is idempotent: no writes once there is nothing left, so running it on every connect is safe. Legacy and new cannot be confused, which is what makes the purge safe: the new scheme writes to drift and the legacy keys are in prefs, so no length or shape heuristic is needed to tell them apart. That is decision 2 making decision 1 unambiguous. **Wiring:** StorageService is shared with PathHistoryService and TimeoutPredictionService, so the connector cannot own it like the other per-radio stores. It is injected from main() and fed the device key on SELF_INFO alongside them. Unset, both surfaces load nothing and never write, so a read before the handshake cannot serve another radio's data (#754 precedent). **Tests: Epic A's bleed tests inverted, 11 of them.** Every expectation that demanded the bleed now demands isolation, and the before/after is the git history of one file. Plus the purges: prefix-only, idempotent, and the scoped data surviving the purge of the unscoped. **Not done, and flagged rather than dropped:** the plan's B2 also said "bound what persists to what PathHistoryService actually caches". That bound existed to stop the prefs file growing. In drift a write costs one row and growth costs nothing per write, so the bound would now delete regenerable route history for no benefit. Skipping it is a judgement, not an oversight, and it is called out here and in the PR. The inventory's known-holes set is empty for the first time: {854, 855, 856} at Epic B's start, then {854, 856} after B1, now none. An Epic A sanity assertion that holes must exist failed on that and was corrected, because the invariant that matters is conditional: if a hole is declared it must name an issue, and empty satisfies it. Citadel: meshcore-open-0z3, meshcore-open-z8t Refs #937, #854, #856, #924, #853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
a7882b62e0 |
task(#937): B1 delete the dead pending_messages API
#855 was filed as "pending messages are stored globally, not per radio". It is not a scoping bug. `loadPendingMessages`, `savePendingMessages` and `clearPendingMessages` had no callers anywhere in lib/ or test/, and the key was absent from the owner's live prefs. The `_pendingMessages` field in message_retry_service.dart is an unrelated in-memory list that happens to share the name. So the surface is DELETED, not scoped: three methods and the key constant. Scoping it per radio would have been three careful changes and a migration decision, for an API nothing calls. This is the finding that justified consolidating #854, #855 and #856 into one Epic instead of landing three PRs. Nobody reading #855 on its own would have gone looking for callers, because the issue already told them what the fix was. Removed with it: - the inventory row, replaced by a comment saying the surface is GONE rather than reclassified, so the history is readable - A2's round-trip test, whose job was to prove the thing being deleted actually worked. It served that and then became a test of nothing - 855 from the known-holes expectation, which now reads {854, 856}; that set shrinking to empty is what closing Epic B looks like What still guards it: the inventory's "declared but vanished from source" check fires if `pending_messages` ever comes back undeclared. Citadel: meshcore-open-li1 Refs #937, #855, #853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
5f856f10c9 |
task(#936): A2 reproduce the two remaining bleeds against the real store
Five tests pinning exactly what bleeds today, so Epic B has something concrete to invert and cannot quietly half-fix it. They use the REAL StorageService against real prefs, not the FakeStorageService the existing path-history tests use, because the defect is in the KEY rather than the service logic and a fake hides it entirely. #854: the stored key is prefix + contact with no radio component, and a 7-hop route saved by one radio is served verbatim to another. A route measured by a 1 W handheld gets applied to a repeater. #856: observations live under one global key, and a service standing in for a second radio starts up with the first radio's 45-second observation already in its training data, having sent nothing. #855: a round-trip test only, pinning that the API works before Epic B deletes it, so the deletion is provably removal of dead code rather than of a live feature. That it has no callers is a grep result recorded in the inventory, which a unit test cannot assert. These assert the BUG deliberately and are named so nobody mistakes them for desired behaviour: each title says BUG and names what Epic B replaces it with. The plan's A2 asked for genuinely-red tests; a red test cannot merge, and Epic A's PR has to land before Epic B starts, so these are characterization tests instead. They demonstrate the bleed just as concretely and go red the moment it stops, which is the property that matters. Flagged here rather than quietly substituted. Two API names in the first draft were invented (loadHistoryFor, loadObservations) and did not exist. Replaced with the real ones after reading the services: the path-history case now exercises StorageService directly, which is where the defect actually is. Citadel: meshcore-open-ss9 Refs #936, #853, #854, #855, #856 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
9ffa9a3c0b |
task(#936): A1 scoping inventory, with a gate that already caught four things
Feature #853's acceptance bar is that every store has "a verified scoping verdict and no hole remains". Until now, verifying that meant a human re-reading an audit in an issue body, which is how three separate bleeds (#754, #854, #856) survived earlier rounds of bleed fixes. `scoping_inventory.dart` is that audit in code: every persistence surface with perRadio / globalOnPurpose / knownHole, a mandatory reason, and the file that owns it. A reason is required because an unexplained verdict is how a settled surface gets re-opened as a hole later. The test makes it a gate. It scans lib/ for the shape every key in this repo uses, a `const String` with a lower snake_case value, and fails when one is neither a declared surface nor an explicitly justified non-key. It also checks the reverse: a surface declared here but gone from the source. That one will fire when Epic B deletes `pending_messages`, which is intended. **It caught four things on its first two runs**, which is the argument for building it before Epic B rather than after: - `channel_cyr2lat_` and `contact_cyr2lat_` were never audited. #853 listed channel_settings_store as one surface; it owns two keys. - `los_error_elevation_unavailable`, a third error sentinel I had not found by hand. - `__msgrows_migrated`, declared by me and then reported missing, because my pattern required a letter first and internal markers start with underscores. The gate caught my own declaration bug. Scope of the scan, stated so nobody mistakes it for total: it would miss a key built inline with no constant, and a key that is not snake_case. Scanning every string literal in lib/ was tried and rejected, because it fires on asset paths and log tags, and a check that cries wolf gets worked around. That is the #927 lesson, which is also why there are three tests that prove this gate can fail rather than assuming it. Citadel: meshcore-open-z36 Refs #936, #853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
7b09bbfe5e |
docs(#853): fold the owner's three decisions into the plan
Owner chose A on all three, 2026-10-20. Canon says a plan is rewritten so the new design IS the plan, rather than leaving answered choices sitting as options, so section 9 is now a Decisions table and nothing in the document still asks a question. 1. The legacy untagged data is DELETED, not orphaned: the 2,041 path_history_<contact> keys and the global delivery_observations. Both regenerate, and here the orphaned data IS the performance problem, which is why this differs from #754's orphan choice. 2. Path history MOVES into drift rather than gaining a prefix in place, one migration instead of two. 3. #158 and #190 stay in the Feature as Epic D. Propagated to the places the decisions change behaviour, not just section 9: B2 and B3 now state the legacy delete and gate it behind the per-radio readback, and the two can't-tell rows in section 6 name decision 1 instead of pointing at an open choice. grant-terms re-validated: zero errors, chain [936-940], budget 5. Citadel: meshcore-open-rmr Refs #853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
a2cc72f40a |
docs(#853): plan of record for radio-profile isolation
#853 had an audit in its issue body and nothing else: no plan doc, no merged
PLAN.md, no grant. That is why #754 and #471 sat written-but-unmerged for
four days with nowhere to go, and why its other children were filed as three
separate bugs against one file.
Written from the canonical FEATURE-PLAN template. Five Epics (#936 to #940),
ordered base-capability first, Integration Testing last per canon.
Three things the research changed from #853's own audit, which was taken at
|
3 weeks ago |
|
|
b59bc728fc |
fix(#471): don't offload the client block list onto a blank radio
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> |
3 weeks ago |
|
|
a331075a8b |
fix(#754): scope discovered contacts per radio
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> |
3 weeks ago |
|
|
8cea58ec1d |
fix(#931): two defects from the second pre-PR review
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> |
3 weeks ago |
|
|
4ab969b7a1 |
feat(#931): second ANR trigger that blocks the Dart isolate, not Kotlin
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> |
3 weeks ago |
|
|
d2e9358f4d |
fix(#910): four defects from the pre-PR review
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> |
3 weeks ago |
|
|
35e71a9eef |
fix(#910): the ANR trigger subtitle told you the wrong thing
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> |
3 weeks ago |
|
|
94cc24a95f |
fix(#910): hide the ANR test row off Android
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> |
3 weeks ago |
|
|
0a08ef7b06 |
test(#910): exit-record selection, formatting, pruning and rotation
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> |
3 weeks ago |
|
|
18e1453e1f |
feat(#910): force-an-ANR trigger, compiled out of normal builds
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> |
3 weeks ago |
|
|
31bdfb9489 |
feat(#910): ship ANR traces with the exported log
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> |
3 weeks ago |
|
|
24a1a741d7 |
feat(#910): Dart exit-record service, scheduled after the first frame
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> |
3 weeks ago |
|
|
201d75e895 |
feat(#910): Kotlin collector for ApplicationExitInfo exit records
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> |
3 weeks ago |
|
|
bd3a61096a |
docs(#920): record the Epic B outcome and its two departures in the plan
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> |
3 weeks ago |
|
|
dbe52164da |
task(#920): B4 Epic B verification against the owner's real database
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> |
3 weeks ago |