Machine-facing field contract

Every detailed finding below exposes five fixed fields: what is wrong, file and line, evidence, what to fix, and how to prove it. A missing source field is printed as NOT SPECIFIED.

Consolidated finding register — Cadence auditfindings/REGISTER.md · raw .md

Consolidated finding register — Cadence audit

Subject: the app repository at pinned commit 03a176e72ef0075eec86b8915cbe6e93042a3b9d (v0.4.12+18). Every file:line in this table is valid at that commit unless a cell says otherwise.

How to read this table.

  • Final severity is the refuter's grade wherever the refuter re-graded. Original severity is what the stream filed. When the two differ, the verdict cell says SEVERITY CHANGED and the reason is in the refuter note. Where a stream has no refutation yet, the verdict cell says UNREFUTED — verdict provisional and the final severity is the stream's own, carried forward unverified.
  • Verdict is one of CONFIRMED, REFUTED, SEVERITY CHANGED, PARTIALLY REFUTED, or UNREFUTED — verdict provisional. A REFUTED row stays in the register on purpose: it records work that has already been done and must not be rediscovered.
  • Findings contributed by refuters (MISS-*, S2R-*, S3R-*, R5-F1, S6R-*, S7R-*, S9R-*, M1M4, R-F1R-F7, S12R-*, S14R-*) are first-class rows. Their "Original severity" is — (new) because no stream filed them.
  • Merged rows carry every source ID in the ID cell. Independent convergence — two streams reaching the same defect from different angles — is a strength of the evidence, and the ID cell is where it is visible.
  • Reassigned rows. S14's refuter proved S14 audited a scope deleted from the plan before Phase 1 (S14R-M3). Five S14 findings are reassigned: F6→S10, F7→S6, F8→S9, F9→S9, F11→S7. The Stream column shows the corrected owner with the origin in brackets.

Unrefuted stream: S4 only. agent_reports/S8_refute.md and agent_reports/S13_refute.md landed during compilation and are fully folded in; proof/01_findings/S4_refute/ exists, so a refutation was in flight, but no agent_reports/S4_refute.md had landed. 15 rows are therefore provisional, all of them S4's. Where an S4 finding was independently reached by another stream whose refuter did report, the merged row carries that refuter's verdict and is not provisional.


Severity definitions (AGENT_RULES.md R13)

Grade Definition
BLOCKER Prevents store submission, or loses/corrupts a user's data, or fails to ring an alarm
HIGH Wrong behaviour during service
MEDIUM Quality, maintainability, or a degraded path
LOW Cosmetic or stylistic
INFO Recorded non-defect: the stream checked the area and found nothing, or reported a decision rather than a defect

1. BLOCKER

ID Title Final severity Original severity Verdict File:line (at 03a176e) Evidence path Refuter note
S1-F1 A backward step of the wall clock silences every running timer for the length of the step BLOCKER BLOCKER CONFIRMED lib/engine/engine.dart:312; deadlines set at :177, :182; consumer lib/ui/home.dart:248 findings/S1_engine.md · proof/01_findings/S1/ · proof/01_findings/S1_refute/ Reproduced on an independently built clean copy; heartbeat proven monotonic (so the app is awake and silent); backstop proven to run on the same RTC_WAKEUP wall clock, so it does not rescue this. Three corrections: the Evidence block is a stitched composite, not verbatim — replace it with s1_facts_test.dart:92-121 as written; the silence is bounded by the size of the step, not permanent; the proposed fix is defective as written (probe R2b) and must be redesigned before implementation.
S2-F2 The zone→sound migration is NOT idempotent: a second run rewrites every dish's ringtone to Bell BLOCKER BLOCKER CONFIRMED (strengthened) lib/engine/store.dart:255-285; false claim at :249-252; mechanism lib/engine/models.dart:56-63 findings/S2_persistence.md · proof/01_findings/S2/ · proof/01_findings/S2_refute/ Reproduced twice from scratch, once with no write failure at all — the trigger needs no wrong-typed value. One wording correction: _guard (store.dart:174-180) does observe and report the write result; what it does not do is raise it as critical, retry, or let a caller await it. Delete "never verifies" before this reaches the report.
S3-F1 + S9-F07 (+ prior A1-1, iOS half) The OS alarm backstop is dead on iOS: the notification plugin is initialised Android-only and no iOS sound resource is bundled, so the app boots into a permanent red banner and iOS has nothing to play BLOCKER BLOCKER (S3) / HIGH (S9) CONFIRMED — S9-F07 raised HIGH→BLOCKER lib/alarm_backstop.dart:72-76 (InitializationSettings has no DarwinInitializationSettings); _ready gate :79-96; ios/Runner.xcodeproj/project.pbxproj PBXResourcesBuildPhase block 97C146EC1CF9000F007C117D (no sound resource) findings/S3_audio_alarms.md · findings/S9_platform_config.md · agent_reports/S3_refute.md · agent_reports/S9_refute.md Found independently by S3 (audio/backstop angle) and S9 (platform-config angle). S3's refuter verified the defect against the plugin source at flutter_local_notifications_plugin.dart:142-147 and by mutation B4. S9's refuter raised its own F07 to BLOCKER and folded S9-F03's blocking condition into it. Also the still-open half of the 2026-07-19 prior audit's A1-1.
S3-F2 + S9-F03 An iOS build cannot ring at all once it leaves the screen: UIBackgroundModes is absent from the whole Info.plist BLOCKER BLOCKER (both) CONFIRMED — S9-F03's proposed fix REFUTED ios/Runner/Info.plist:4-69 — key absent from the top-level <dict> (file is 70 lines, not the 63 S3's manifest records) findings/S3_audio_alarms.md · findings/S9_platform_config.md · agent_reports/S3_refute.md · agent_reports/S9_refute.md Both refuters enumerated the plist keys themselves and confirmed the absence (grep -c UIBackgroundModes → 0; 18 top-level keys). S9's refuter split the finding: the fact holds, but adding UIBackgroundModes does not fix it — the blocking condition belongs to S9-F07 (the merged row above) and that is where the Phase-4 fix goes. Do not implement S9-F03's fix as written.
S3-F3 setStreamVolume failure is swallowed in Kotlin and never verified in Dart, so the 15 % audible floor is a claim the app cannot back BLOCKER BLOCKER PARTIALLY REFUTED — BLOCKER stands, mechanism refuted android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:57-59 findings/S3_audio_alarms.md · agent_reports/S3_refute.md §Part 1 · proof/01_findings/S3_refute/ The severity survives; the justification does not. The refuter went to AOSP AudioService.java:4495-4504, :3745-3765, :4613-4626, :6724-6752 and proved the cited SecurityException cannot fire at this call site — the app passes flags = 0, and the throw is guarded by wouldToggleZenMode(getNewRingerMode(...)) which needs FLAG_ALLOW_RINGER_MODES. Consequence: fix (1) (reply result.error(...)) is inert; promote fix (2) to the whole remedy. See S3R-F2.
S9-F01 The release build type signs with the debug keystore; both APK and AAB carry CN=Android Debug, which Google Play does not accept BLOCKER BLOCKER CONFIRMED android/app/build.gradle.kts:30-36 findings/S9_platform_config.md · proof/01_findings/S9_refute/ Reproduced on the refuter's own build of both artefacts. Policy source: https://developer.android.com/studio/publish/app-signing.
S9-F02 + R-F1 (S11's refuter) The iOS App Store icon set is the byte-identical Flutter placeholder logo; flutter_launcher_icons was configured to skip iOS BLOCKER BLOCKER (S9) / — (new, S11R) CONFIRMED and strengthened pubspec.yaml:35 (ios: false); ios/Runner/Assets.xcassets/AppIcon.appiconset/*.pngall 15, not only Icon-App-1024x1024@1x.png findings/S9_platform_config.md · agent_reports/S9_refute.md §BLOCKER 2 · agent_reports/S11_refute.md §7 R-F1 Found twice, independently. S9 found it from the platform-config side and graded BLOCKER; S11's refuter found it from the asset-provenance side after discovering S11 had omitted ios/ from its inventory entirely (R-F2), and graded it BLOCKER without knowledge of S9's row. S9's refuter proved all 15 icons are template, not just the 1024. Root cause is a single line: pubspec.yaml:35.
S9-F04 The iOS project has never been configured for a real signing identity, and the required Xcode/SDK toolchain is not installed BLOCKER BLOCKER CONFIRMED ios/Runner.xcodeproj/project.pbxproj:349, :469, :526 (no DEVELOPMENT_TEAM) findings/S9_platform_config.md · agent_reports/S9_refute.md §BLOCKER 4 Confirmed, and the 2026-04-28 Xcode-26 requirement date verified verbatim against https://developer.apple.com/news/upcoming-requirements/.

BLOCKER count: 8 rows. S9's refuter's summary holds — the platform stream's BLOCKER count is unchanged at 4, but one of them changed identity (F03 → F07), and with it the Phase-4 fix.


2. HIGH

ID Title Final severity Original severity Verdict File:line (at 03a176e) Evidence path Refuter note
S2-F4 A timer created after v0.4.11 is forced to Bell if the migration ever re-runs HIGH HIGH CONFIRMED lib/engine/store.dart:273-279 findings/S2_persistence.md · proof/01_findings/S2_refute/ The loop has no legacyZoneId == null guard. Unlike S2-F3 this is reachable through the S2-F2 kill window and needs no wrong-typed value.
S2-F5 + S5-F4 A failed journal write discards the buffered lines permanently and tells nobody; Journal.ready keeps returning true HIGH HIGH (S2) / MEDIUM (S5) CONFIRMED lib/journal.dart:163-180, specifically :167-168 (the _buf.clear() before the write) and :175-177 (debugPrint only); ready => _file != null at :53; Settings block stays up at lib/ui/modals.dart:673 findings/S2_persistence.md · findings/S5_error_handling.md · agent_reports/S2_refute.md · agent_reports/S5_refute.md Found twice. S2 filed the data-loss half at HIGH and reproduced it (probe J1: the two lines logged during the outage are absent, Diag.log=0, Journal.ready=true). S5 filed the reporting half at MEDIUM and its refuter recorded that S5 missed the data loss sitting in the same six lines, and that S5's proposed fix ("stamp !! ECRITURE JOURNAL IMPOSSIBLE into the buffer for the next flush") is not implementable — by the time the handler runs the buffer is already cleared. Take S2's grade and drop S5's fix.
S2-F6 + S5-F3 Journal rotation erases the entire journal when the file cannot be decoded HIGH HIGH (both) CONFIRMED (S2's refuter) / SEVERITY CHANGED to MEDIUM (S5's refuter) — see CONTRADICTIONS.md §5 lib/journal.dart:192-203 (catch (_) { await f.writeAsString(''); }), triggered from :72 findings/S2_persistence.md · findings/S5_error_handling.md · agent_reports/S2_refute.md · agent_reports/S5_refute.md Found twice, and the two refuters disagree on severity. S2's refuter reproduced the destruction (probe J3: 3,146,812 bytes → 167 bytes) and kept HIGH; lcov confirms 192,194,195,196,197,199,201 are all uncovered. S5's refuter downgraded to MEDIUM on the ground that the trigger needs the file to first exceed _maxBytes = 3 MiB. Resolved HIGH — S12's refuter reconciled journal growth at 237 KiB/day, so the 3 MiB cap is crossed in 13 days of kiosk uptime, which makes the trigger reachable inside a fortnight. S2 owns journal.dart depth; S5's row is duplicated effort, not a boundary gap.
S2-F7 A dead journal is completely silent: no diagnostic, no banner, and every later event vanishes HIGH HIGH CONFIRMED lib/journal.dart:108-111 and :133-134 (also :208, :233) findings/S2_persistence.md · proof/01_findings/S2_refute/ Probes J2/J7 reproduce.
S2-F8 Diag.fail can itself throw, and the throw escapes the catch block that called it HIGH HIGH CONFIRMED lib/diagnostics.dart:29 findings/S2_persistence.md · proof/01_findings/S2_refute/ Independently reproduced (test R8a): escaped=Bad state: toString exploded, Diag.log=0, critical={}. All ten store.dart call sites cited (:38,47,56,120,126,137,142,145,176,178) verified valid.
S2-F9 Corrupt timers plus a wrong-typed seeded flag installs the demo kitchen over a real one HIGH HIGH CONFIRMED (strengthened) lib/engine/store.dart:300-310 findings/S2_persistence.md · proof/01_findings/S2_refute/ Reproduced (R9a), and a variant needing no wrong type at all was proven (R9b): JSON-corrupt cadence-timers-v1 plus an absent cadence-seeded-v1, on any post-migration tablet where store.dart:283 already removed the zones key, seeds the seven demo dishes. This finding stands on its own reachable trigger and does not fall with S2-F1.
S2R-M1 A corrupt clones value silently destroys the run entries of every batch in flight HIGH — (new) New (refuter contribution) lib/engine/store.dart:65 and lib/engine/engine.dart:76-80 agent_reports/S2_refute.md §7 load decodes the three keys independently, but reconcile() couples them: losing the clones list deletes every run entry belonging to a batch, under a key that was never damaged, with no .corrupt backup taken. Reachable through the ordinary JSON path (row C1) — no wrong type needed.
S3-F4 The TTS engine's "I do not have that language" answer is discarded, so the voice can be silently wrong for a whole service HIGH HIGH CONFIRMED android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:90-94 findings/S3_audio_alarms.md · agent_reports/S3_refute.md §Part 2 The refuter read MainActivity.kt in full, re-derived the eight catch (_: Exception) sites by grep, opened each, and traced every one to its result.* reply and then to the Dart consumer. The "8 of 8, nobody informed" claim holds site by site.
S3-F5 The journal records an announcement as spoken before the native side has had a chance to fail, and the native false is thrown away HIGH HIGH CONFIRMED lib/audio/voice.dart:165-168 and :134 findings/S3_audio_alarms.md · agent_reports/S3_refute.md Both discarded booleans verified at voice.dart:134 and :167 by grep and by eye. S5's refuter independently strengthened the reachability: MainActivity.kt:132-134 wires UtteranceProgressListener.onError/onStop to completeSpeak(id, false)result.success(false), and speak() at :145 replies false outright when the engine is not ready — all of it discarded after voice.dart:165 has already written parole "…" to the journal.
S3-F6 The backstop reports itself ready when POST_NOTIFICATIONS was refused, and never re-checks the channel afterwards HIGH HIGH CONFIRMED lib/alarm_backstop.dart:79-96, specifically _ready = true after a refused permission at :83-90; consumer :64 findings/S3_audio_alarms.md · agent_reports/S3_refute.md Verified by reading the block.
S3-F7 Exact-alarm revocation on Android 12/12L cancels every backstop, and nothing in the app detects it HIGH HIGH CONFIRMED (two citation corrections) android/app/src/main/AndroidManifest.xml:13-15; lib/alarm_backstop.dart:80 findings/S3_audio_alarms.md · agent_reports/S3_refute.md Permissions verified present. Two of S3's sub-line ranges drift: it cites the boot receiver at :48-56 (actual :47-58, class name at :49) and the <queries> TTS_SERVICE action at :75-77 (actual block :68-78, action at :76). The elements are the ones it names.
S3-F8 The alarm level is re-imposed only on the rising edge of a ring, so the volume rocker can mute an alarm that is already ringing HIGH HIGH CONFIRMED lib/audio/alarm_volume.dart:60-67 (onRunChanged); the only other write path is setLevel :53-56 findings/S3_audio_alarms.md · agent_reports/S3_refute.md Verified by reading the file in full. sane (:44-45) handles non-finite input correctly. S3's proposed onRepeat hook does not yet exist, so its stated red-then-green test is honest.
S3-F9 lib/audio/audio.dart had 5.26 % line coverage: every path a silent alarm takes was untested HIGH HIGH CONFIRMED lib/audio/audio.dart (whole file, 116 lines) findings/S3_audio_alarms.md · agent_reports/S3_refute.md §Part 6 Reproduced to the digit. S3's 40 new tests lift audio.dart to 97.37 % (37/38), voice.dart to 96.59 %, alarm_backstop.dart to 94.00 %, alarm_volume.dart to 100 %, whole project 71.93 % (1386/1927), suite 163 pass / 0 fail, flutter analyze 0 issues — every number reproduced exactly on the refuter's copy. But see S3R-F1: three of those 40 tests do not detect the behaviour they name, so coverage is not the same as protection.
S3-F17 On iOS there is no audible floor at all, and nothing detects a muted device HIGH HIGH CONFIRMED (Dart half by test; iOS half is static analysis only) ios/Runner/AppDelegate.swift:54-59; lib/ui/home.dart:209-218 findings/S3_audio_alarms.md · agent_reports/S3_refute.md The refuter read AppDelegate.swift in full and verified every line reference in the parity table. No Xcode on this machine — the iOS half was never compiled or run, by either agent. Note that S3-F17's own proposed fix raises Diag.fail('volume-device', …, isCritical: true), a scope the operator banner cannot render — see S3R-F3.
S3R-F1 Three of S3's 40 new tests assert a behaviour the whole 163-test suite cannot detect HIGH — (new) New (refuter contribution) proof/01_findings/S3/tests/s3_audio_test.dart:220-235, :321-332, :237-250; subject code lib/audio/audio.dart:62, :69, :111-115, :44-54 agent_reports/S3_refute.md §4 Each of the three names a specific behaviour in its title and then asserts something true whether or not that behaviour exists. Phase 4 is expected to land these files as test/audio_test.dart; landed as-is they carry three assertions that can never go red, in the one module whose failure mode is a silent alarm. One of the three is the test S3 cites as the proof of S3-F10.
S3R-F2 The SecurityException justifying BLOCKER S3-F3 cannot fire at that call site, and 1 of its 3 proposed fixes is inert HIGH — (new) New (refuter contribution) android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:57; AOSP AudioService.java:4495-4504, :3745-3765, :4613-4626, :6724-6752 agent_reports/S3_refute.md §4 The finding S3-F3 survives; its evidence and a third of its remedy do not. Drop fix (1), promote fix (2).
S3R-F3 The operator banner cannot render five of the app's critical scopes, so a critical diagnostic can be raised and shown to nobody — and two of S3's own fixes land in exactly that hole HIGH — (new) New (refuter contribution) lib/ui/home.dart:670-703 (the _criticalBanner scope map, six startsWith prefixes, no else); live offender lib/engine/store.dart:270 agent_reports/S3_refute.md §4 Overlaps and strengthens S5-F5, which found the same hole from the error-handling side and counted one scope. S3's refuter enumerated five unrenderable scopes and showed that S3-F3 fix (3) (Diag.fail('volume-set', …, isCritical: true)) and S3-F17's proposed volume-device scope both land in it. Fix the else branch before implementing either.
S4-F01 The ±10 s / ✕ control row overflows its tile, and at dense boards the stop button is clipped away HIGH HIGH UNREFUTED — verdict provisional lib/ui/tile.dart:600 (the Row), sized by :573-598, hit padding :677-678 findings/S4_ui.md · proof/01_findings/S4/ No agent_reports/S4_refute.md at time of writing.
S4-F02 A dish name shrinks without any floor: 24 characters render at 7.5 px on a 30-tile board HIGH HIGH UNREFUTED — verdict provisional lib/ui/tile.dart:393-415 (FlexibleFittedBox(fit: BoxFit.scaleDown)Text) findings/S4_ui.md · proof/01_findings/S4/ Cross-reference: S10's refuter (M3) found the complementary defect — the name is silently capped at 24 characters with the counter blanked.
S4-F03 16 interactive elements are below the 48 dp minimum, including every control used during service HIGH HIGH UNREFUTED — verdict provisional lib/ui/tile.dart:431-435 (×N chip), :573-598 + :677-678 (± and ✕) findings/S4_ui.md · proof/01_findings/S4/ Corroborated from another angle by S10-F13, whose refuter re-derived the geometry independently and found S4/S10 both understated it: at tile 127×108 the ±10 s button measures 35 px and the ✕ 31.6 px including hit padding.
S4-F04 The 150 ms heartbeat is gated behind three awaits with no timeout; if one never completes the board freezes silently HIGH HIGH UNREFUTED — verdict provisional lib/ui/home.dart:136 (await sounds.init()), :148 (await _initSystemVolume()), ticker created at :154 findings/S4_ui.md · proof/01_findings/S4/ Same code region as R5-F1 (a throw in _boot skips the ticker) and S12-F1 (unmount during the awaits orphans the ticker). Three distinct failure modes, three distinct fixes — do not collapse them. S14's refuter proved the sibling hang in main() by execution (EXIT_CODE=124 at 90 s), which establishes the class.
S6R-F1 An out-of-set tone name reaches assetFor unvalidated and rings a silent alarm HIGH — (new) New (refuter contribution) lib/engine/store.dart:274-277 (the v0.4.11 migration), lib/engine/models.dart:71 (fromJson), lib/engine/engine.dart:66 (soundFor), lib/audio/audio.dart:83-84 (assetFor) agent_reports/S6_refute.md §Findings S6 missed S6's negative result ("Referenced-but-missing WAVs: Zero") is true of C.tones but C.tones is not the reachable input set of assetFor. migrateZoneSounds copies any string out of the legacy zones JSON onto TimerDef.sound with no membership check, and fromJson accepts any string from prefs. Interacts with S3-F19 — see CONTRADICTIONS.md §7.
S7-F1 The 150 ms heartbeat is untested, and its named constant has zero consumers HIGH HIGH CONFIRMED (not individually re-graded) lib/engine/engine.dart:32 and lib/ui/home.dart:154 findings/S7_tests.md · agent_reports/S7_refute.md The refuter reproduced grep -rn "Engine.tickMs" test/zero references, and re-applied S7's tickMs 150→5000 mutation itself: +123: All tests passed!, EXIT_CODE=0. The dead-constant half is the same defect as S6-F2; the untested-heartbeat half is S7's own and is not duplicated.
S7-F2 Every assertion about the alarm lead is computed from the constant it claims to test HIGH HIGH CONFIRMED test/engine_test.dart:61, :145, :166, :172; test/robustness_test.dart:291 findings/S7_tests.md · agent_reports/S7_refute.md §10 Listed by the refuter under "what I did not overturn — verified verbatim at source". S1's refuter independently re-applied the alarmLeadMs → 0 mutation and got +123: All tests passed!.
S7-F3 lib/ui/home.dart (722 lines) has no test, and cannot be time-tested as written HIGH HIGH CONFIRMED (not individually re-graded) lib/ui/home.dart:248; file coverage 0.00 % findings/S7_tests.md · proof/00_baseline/coverage.txt The refuter accepted the clock-injection prerequisite as real: its audit of the home_test.dart specification says the spec is implementable "after the S7-F3 clock injection, which the spec names as a prerequisite".
S7-F4 The backstop's past-deadline guard is not what makes its own test pass HIGH HIGH CONFIRMED lib/alarm_backstop.dart:181; test test/backstop_test.dart:88-96 findings/S7_tests.md · agent_reports/S7_refute.md §10 Listed under "what I did not overturn — verified verbatim at source".
S7-F5 No backstop test uses a chained timer, so the final-deadline rule is unprotected HIGH HIGH CONFIRMED and strengthened lib/alarm_backstop.dart:108-112; test/backstop_test.dart:70-79 findings/S7_tests.md · agent_reports/S7_refute.md §10 "Strengthened by execution counts S7 did not measure."
S7-F6 Nothing proves the backstop cancels the OS alarm of a timer that was stopped HIGH HIGH CONFIRMED (not individually re-graded) lib/alarm_backstop.dart:127-129 findings/S7_tests.md · agent_reports/S7_refute.md S10's refuter independently verified the same cancel path from the product side (:127-128 cancels the armed alarm the moment a run stops matching _desired).
S7-F8 The voice queue's pacing, staleness and cancellation guards all survive mutation HIGH HIGH CONFIRMED and strengthened lib/audio/voice.dart:177 (300 ms gap), :184 (20 s staleness), :174 (generation guard) findings/S7_tests.md · agent_reports/S7_refute.md §10 "Strengthened by execution counts S7 did not measure."
S7-F13 The ringtone-asset test cannot detect a filename-case mismatch on macOS HIGH HIGH CONFIRMED test/i18n_defaults_test.dart:70-78; subject lib/audio/audio.dart:83-84 findings/S7_tests.md · agent_reports/S7_refute.md §10 Verified verbatim at source.
S7-F16 + S4-F13 + S5-F9 Six lib/ files — 837 of 1,927 instrumented lines, 43.4 % of the codebase — are executed by zero tests HIGH HIGH (S7) / MEDIUM (S4) / MEDIUM (S5) CONFIRMED and strengthened lib/main.dart, lib/ui/home.dart (722), lib/ui/tile.dart (819), lib/ui/header.dart (215), lib/ui/theme.dart, lib/ui/logo.dart — all 0.00 % findings/S7_tests.md · findings/S4_ui.md · findings/S5_error_handling.md · proof/00_baseline/coverage.txt Found three times, from the test, UI and error-handling angles. S7's refuter turned the aggregate into a measured consequence: the six 0 %-coverage files hold 43.4 % of instrumented lines and took 2 of S7's 57 mutations; line-proportional sampling would have placed ~25, and every one of those would have survived by construction. S5's refuter noted S5-F9 partly double-counts a baseline fact and survives only as maintainability.
S7R-F4 lib/ui/modals.dart (300 instrumented lines, 65 % covered) received zero mutations; five of six the refuter placed there survive HIGH — (new) New (refuter contribution) lib/ui/modals.dart:217, :303, :428, :436, :523, :524 agent_reports/S7_refute.md §5 · proof/01_findings/S7_refute/extension_results.json The second-largest instrumented file, the only place a cook edits a timer, reached by two widget-test files — and nothing in the audit said whether its 65 % coverage carries any assertions. It largely does not: the editor's own duration floors and clamps are unprotected, including the 5 s per-step floor and the 0..59 seconds clamp. Those clamps also duplicate Engine.saveDef (modals.dart:520-525 vs engine.dart:368) — R7 territory that S6 missed.
S7R-F5 lib/ui/tile.dart (819 lines, the largest file in the app) received zero mutations; all three the refuter placed survive HIGH — (new) New (refuter contribution) lib/ui/tile.dart:183, :200, :498 agent_reports/S7_refute.md §5 · proof/01_findings/S7_refute/extension_results.json The three probed outputs are the idle duration, the ringing count-up and the chained step count — the single thing a cook looks at across the pass. All three can be broken outright with the suite fully green.
S8-F1 + S14R-M2 The app declares no Flutter localisations, so every framework string is English whatever the app language HIGH HIGH (S8) / MEDIUM (S14R) CONFIRMED at HIGH by S8's refuter lib/main.dart:43-56 — the MaterialApp passes no localizationsDelegates, no supportedLocales, no locale; flutter_localizations absent from pubspec.yaml and pubspec.lock findings/S8_i18n.md · agent_reports/S8_refute.md · agent_reports/S14_refute.md §3 S14R-M2 Found twice, independently, and settled. S8 filed it at HIGH; S14's refuter found the identical defect at the identical lines from the app-entry side and graded it MEDIUM, with the mechanism spelled out: Flutter installs DefaultMaterialLocalizations, whose locale is fixed at en_US. S8's own refuter, working without knowledge of S14's, confirmed HIGH — that is the governing grade. See CONTRADICTIONS.md §6. Related but distinct: S9R-M03 (the iOS bundle declares English only).
S8-F2 The generated spoken announcement is grammatically wrong in both languages, and v0.4.7 replaced correct English with it HIGH HIGH CONFIRMED, including the regression, against git history lib/i18n.dart:148-150; migration at lib/engine/store.dart:187-231 findings/S8_i18n.md · agent_reports/S8_refute.md §1 The refuter re-derived the v0.4.7 English regression against git history, not against the finding's prose. Interacts with S2-F10 (repairGeneratedPhrases deletes the operator's own sentence if it re-runs) — same migration, confirmed by S2's refuter — and with S8-R2, which shows the operator is shown the ungrammatical phrase live in the editor, not merely told it.
S8-F3 A French first launch seeds an English board and speaks English dish names in French sentences HIGH HIGH CONFIRMED lib/engine/store.dart:326-343 findings/S8_i18n.md · agent_reports/S8_refute.md §1 Confirmed by S8's refuter, and the seed block is independently verified verbatim by S10's refuter for a different purpose (store.dart:296, 326-343, including const oven = 'Cascade', fryer = 'Chirp').
S13-R2 (S13's refuter) S13-F5 and S13-F2 compound into silent loss of the only configuration backup: roughly nine journal exports blow Android's 25 MB Auto Backup quota and cloud backup stops, unannounced HIGH — (new) New (refuter contribution) lib/journal.dart:25-26 (_maxBytes = 3 MB, _keepBytes = 1 MB), :221-231 (the export copy) agent_reports/S13_refute.md §3 Three facts each present in S13's report and never multiplied: (a) every export writes a new dated copy into getExternalStorageDirectory() and nothing deletes any of them; (b) that directory is Context.getExternalFilesDir(null), which Android's Auto Backup documentation lists among the directories it backs up by default; (c) Auto Backup is capped at 25 MB per app, past which onQuotaExceeded() fires and the system stops backing the app up entirely. Each rotated copy is 1–3 MB. A pilot kitchen exporting daily crosses the quota inside a fortnight — and this is the evidence that settles the S10-F1 / S13-F4 severity dispute (CONTRADICTIONS.md §6): the platform default S10's refuter relied on is not dependable.
S9-F05 USE_FULL_SCREEN_INTENT is declared and used, but the app never requests it at runtime and has no degraded path if Play declines the auto-grant HIGH HIGH CONFIRMED android/app/src/main/AndroidManifest.xml:16 and lib/alarm_backstop.dart:53 findings/S9_platform_config.md · agent_reports/S9_refute.md §Part 4 · store_readiness/01_google_play.md §5.2 Confirmed. The eligibility question is disputed between the Play-readiness agent and S9's refuter — both point to the same fix, so the dispute does not change what Phase 4 does. See CONTRADICTIONS.md §2.
S9-F06 MODIFY_AUDIO_SETTINGS is declared but ACCESS_NOTIFICATION_POLICY is not, so the alarm-volume write throws under Do Not Disturb and the exception is swallowed HIGH HIGH CONFIRMED android/app/src/main/AndroidManifest.xml:8 declared, ACCESS_NOTIFICATION_POLICY absent from lines 2-17; consumed at MainActivity.kt:57 findings/S9_platform_config.md · agent_reports/S9_refute.md §Part 4 Confirmed by S9's refuter against https://developer.android.com/reference/android/media/AudioManager. Read alongside S3R-F2, which proves the SecurityException from setStreamVolume with flags = 0 cannot fire — the two findings name different throw paths and Phase 4 must not conflate them.
S10-F2 Every destructive action in the product is one unconfirmed tap with no undo HIGH BLOCKER SEVERITY CHANGED (BLOCKER → HIGH); sub-claim REFUTED lib/ui/tile.dart:618-625 (✕), lib/ui/modals.dart:348-351 (🗑); the HIGH is carried by lib/engine/engine.dart:378-380 (Save kills a live run and every batch of it) findings/S10_product.md · agent_reports/S10_refute.md §1, §3 "No confirmation, no undo" is CONFIRMED and total: grep -rniE "\bundo\b\|AlertDialog\|showDialog.*confirm" lib/ → exit 1, no output; grep -rn "onLongPress\|onDoubleTap" lib/ → exit 1, no output. "Four one-tap gestures" is REFUTED — only one of the four is one tap, and a different one touches the menu. A missing confirmation is none of R13's three BLOCKER conditions.
S10-F3 In edit mode, tapping a ringing tile opens the editor instead of silencing the alarm HIGH HIGH CONFIRMED lib/ui/home.dart:349-353 (S10 cited :348-353), tested before r.status == RunStatus.ringing at :365; lib/ui/tile.dart:569-570 renders the ±/✕ row only for running/paused findings/S10_product.md · agent_reports/S10_refute.md editing defaults false (home.dart:40), is not persisted, and nothing auto-exits it — so the tile carries no escape while ringing. Severity stands.
S10-F14 What the operator experiences in each service failure, assuming the OS backstop does not ring HIGH HIGH CONFIRMED, incomplete lib/alarm_backstop.dart:99-116, :122-149, :177-221, :238-248; lib/ui/home.dart:174-204 findings/S10_product.md · agent_reports/S10_refute.md Every row of the table checks out against the code, including the Wi-Fi row (no INTERNET permission in the manifest). Incomplete on one row: the _desired filter at :104 also excludes paused, and the table has no paused row — that gap is M1 below.
S10-F1 + S13-F4 A restaurant's entire configuration exists in one copy, on one tablet, with no in-app export and no import HIGH BLOCKER (S10) / HIGH (S13) S10's BLOCKER SEVERITY CHANGED and its absolute claim REFUTED; S13's HIGH CONFIRMED by S13's own refuter — HIGH governs lib/engine/store.dart:15-24 (the only persistence keys); lib/journal.dart:207 (the only export in lib/, and it exports the flight recorder, not the configuration); Settings surface lib/ui/modals.dart:616-693 findings/S10_product.md · findings/S13_data.md · agent_reports/S10_refute.md §1 · agent_reports/S13_refute.md · research/01_prior_work.md A1-4 / A1-R5 Found three times — S10, S13, and the 2026-07-19 prior audit (A1-4 and A1-R5, both recorded STILL OPEN) — and the two refuters disagree. S10's refuter downgraded to MEDIUM because Android Auto Backup supplies backup, restore and second-device transfer by platform default for exactly the cadence-timers-v1 key. S13's refuter confirmed HIGH. Resolved HIGH: S13-R2 measured that the platform default S10's refuter relied on stops silently once roughly nine journal exports exhaust the 25 MB per-app quota, so it cannot be treated as the restaurant's backup. BLOCKER remains wrong — a missing export is none of R13's three conditions. Full resolution in CONTRADICTIONS.md §6.
M1 (S10's refuter) One accidental tap on the biggest target on the board converts a cooking dish into one that will never ring, and cancels its OS safety net HIGH — (new) New (refuter contribution) lib/ui/home.dart:387-399, lib/engine/engine.dart:243-251, lib/alarm_backstop.dart:104-106 and :127-128 agent_reports/S10_refute.md §6 A single tap on a running tile pauses it. The tile is the largest touch target on the board and the same target the product asks the cook to slap to silence an alarm. pauseTimer sets endsAt = null, so the engine will never fire it, and _desired then drops it so the scheduled OS alarm is cancelled too. Both layers of protection removed by one accidental tap.
S11-F1 The shipped app contains three OFL-licensed font families with no copyright notice and no licence text HIGH BLOCKER SEVERITY CHANGED (BLOCKER → HIGH); REFUTED as stated pubspec.yaml:48-69 (seven fonts declared); repository root (no licence file) findings/S11_asset_licensing.md · agent_reports/S11_refute.md §1 The factual predicate is wrong. All seven binaries ship the copyright notice in name ID 0/13, and DSEG7Classic-Bold.ttf ships the complete OFL 1.1 text inside name ID 13, byte-identical to the licensor's published copy. The real defect covers six files, not three families. Termination is further contested by the licensor's own FAQ 1.10. The release APK shipping no font notice is separately CONFIRMED, and LicenseRegistry/showLicensePage was proven from Flutter SDK source to surface nothing. Rewrite the finding before implementing.
S12-F9 After a reboot nothing brings the board back; only the OS notifications survive HIGH HIGH CONFIRMED android/app/src/main/AndroidManifest.xml:22-43 — the only activity, MAIN/LAUNCHER-only intent filter findings/S12_lifecycle.md · agent_reports/S12_refute.md §6 Confirmed from the merged manifest read in full. One of the three S12 findings confirmed as written.
S12R-F3 S12's own Backstop.dispose() fix leaves the OS safety net on a stale deadline HIGH (conditional: only if Phase 4 adopts fix_announce_timer_leak.patch as written) — (new) New (refuter contribution) The patch's second hunk, against lib/alarm_backstop.dart:266; the policy it contradicts is at lib/alarm_backstop.dart:240-242 agent_reports/S12_refute.md §2.1 · proof/01_findings/S12_refute/r6_backstop_dispose_WITH_s12fix.txt Measured both ways. Unpatched, a teardown inside the 300 ms debounce window leaves the OS holding the new deadline — correct. With S12's patch applied it holds the stale one, ten seconds later than the cook asked for. That is the BLOCKER class of failure traded for a duplicate notification that was never wrong. The correct dispose() is _flushSchedules(); _debounce?.cancel(); _debounce = null; _pending = null; — the same policy the class already applies at onBackground().
S13-F1 The exported journal carries operator free text and a device description off the device, unredacted HIGH HIGH CONFIRMED lib/journal.dart:205-237 (export) and lib/ui/modals.dart:696-717 (share) findings/S13_data.md · agent_reports/S13_refute.md Confirmed by S13's refuter, and the export path is independently verified by S5's refuter for a different defect (modals.dart:698-717 and journal.dart:233-236 verbatim-exact). Related: S10-F12 (the journal is the operator's only record and it is French-only).
S13-F5 The journal writes an unmanaged second copy to external storage that nothing ever deletes HIGH MEDIUM SEVERITY CHANGED (MEDIUM → HIGH, raised) — "confirmed, and under-stated" lib/journal.dart:228-231 findings/S13_data.md · agent_reports/S13_refute.md §3 S13-R2 Raised by its own refuter. The unmanaged copies are not merely clutter: they sit in a directory Android backs up by default, each is 1–3 MB, and roughly nine of them exhaust the 25 MB per-app Auto Backup quota, at which point cloud backup stops silently for the whole app (S13-R2). The surrounding block (:226, :231) is independently verified verbatim by S5's refuter for S5-F10.
S13-F2 + S9-F10 + M4 (S10's refuter) Android auto-backup is on by default, so the journal, the kitchen's entire menu and every preference are copied into the operator's Google account and restored onto any device HIGH HIGH (S13) / MEDIUM (S9) / — (new, S10R) Facts CONFIRMED by three sources; S13's store-declaration rationale REFUTED; severity HIGH per S13's refuter android/app/src/main/AndroidManifest.xml:18-21 — the <application> element (S13 cited :20-23; :22-23 are <activity / android:name=".MainActivity") declares only label, name and icon; no android:allowBackup, no android:dataExtractionRules, no android:fullBackupContent findings/S13_data.md · findings/S9_platform_config.md · agent_reports/S13_refute.md · agent_reports/S10_refute.md §6 M4 · agent_reports/S9_refute.md Found three times, from the data-privacy, platform-config and product sides, and refuted twice. S9's refuter proved the absence from its own merged manifest (0 occurrences) and confirmed MEDIUM for the config-gap framing; S13's refuter confirmed HIGH for the data-exposure framing. Take HIGH — the two grades are the same omission read as a configuration gap and as a privacy exposure, and the exposure framing is the one with the measured consequence (S13-R2). S13-R3 REFUTES S13's reasoning: Google's Data Safety guidance defines "collect" as transmitting data off a user's device by the developer, and explicitly exempts a user's own backup to their own cloud account. Following S13-F2's rationale would produce a false store filing on both stores. State the fact; do not declare it as collection.
S14-F1 A failure inside Store.open() kills the app before runApp: the tablet shows a blank window forever, nothing logged, nothing on screen HIGH HIGH CONFIRMED and strengthened lib/main.dart:22-35, specifically :24 findings/S14_entry_unowned.md · agent_reports/S14_refute.md §2.1 Test T1 re-run green; realism established from the Android plugin's Kotlin source; the blank-window claim verified against the Flutter engine's own FlutterActivity.java, i.e. reachable without mocking.
S14-F2 Both awaited startup calls can hang forever with no timeout and no watchdog, producing the same blank window with no exception to catch HIGH HIGH CONFIRMED — upgraded from inference to execution lib/main.dart:24-25 findings/S14_entry_unowned.md · agent_reports/S14_refute.md §2.2 S14 argued from absence. The refuter executed it: flutter test killed at 90 s wall clock, EXIT_CODE=124.

HIGH count: 45 rows.


3. MEDIUM

ID Title Final severity Original severity Verdict File:line (at 03a176e) Evidence path Refuter note
S1-F2 + S5-F1 A throwing host callback makes tick() delete the timer it has just set to ringing, and tells nobody MEDIUM HIGH (both) SEVERITY CHANGED (HIGH → MEDIUM), by both refuters independently lib/engine/engine.dart:338-343, try at :305, reached from :286 and :328 findings/S1_engine.md · findings/S5_error_handling.md · agent_reports/S1_refute.md · agent_reports/S5_refute.md Found twice, downgraded twice, on the same ground. Both refuters enumerated every synchronous statement in the four host callbacks reachable from tick() (home.dart:252-258, :284-303, :306-312, :315-334) and proved none can throw synchronously with today's host: Journal.log is guarded, Store.saveRun_write is all-catching, backstop.sync and the sound calls are async. Defence-in-depth, not service behaviour. Two implementation warnings: (a) test/robustness_test.dart:149 is a pre-existing repo test that enshrines this swallow-and-drop as desired behaviour — any fix must deal with it; (b) S5's proposed EngineHost.onEngineFault needs a concrete empty default body, because an abstract member breaks every implementer in lib/ and in test/.
S1-F3 tick()'s own recovery path can throw, abandoning every timer after the failing one MEDIUM HIGH SEVERITY CHANGED (HIGH → MEDIUM) lib/engine/engine.dart:342 (the unguarded re-invocation); in-try calls at :285, :327 findings/S1_engine.md · agent_reports/S1_refute.md Probe B2 reproduces and B2b measures the blast radius. Same reachability ceiling as S1-F2: persistRun has three synchronous leaves and all three are internally guarded or async. Latent robustness defect.
S1-F4 saveDef silently destroys the duration of a chain reduced to one step, and the suite locks the loss in MEDIUM MEDIUM CONFIRMED lib/engine/engine.dart:366-374 (S1 cited :370-374); UI gate lib/ui/modals.dart:505 findings/S1_engine.md · agent_reports/S1_refute.md Probe reproduces durationSec == 5 from a 5,400 s step. Mutation MUT-1 re-applied independently: the suite goes red with exactly one failing test, test/robustness_test.dart "saveDef floors (audit F7)…" — so the suite really does enforce the loss and that test must change with the fix.
S1-F5 A retired lot number is handed out a second time, contradicting the model's own contract MEDIUM MEDIUM CONFIRMED lib/engine/engine.dart:156-159; contract at lib/engine/models.dart:147-149 findings/S1_engine.md · agent_reports/S1_refute.md Probe spawns and stops four times and measures ['p [lot 2]','p [lot 2]','p [lot 2]','p [lot 2]'].
S1-F6 reconcile() repairs the persisted state and writes none of the repairs back MEDIUM MEDIUM CONFIRMED lib/engine/engine.dart:74-100 findings/S1_engine.md · agent_reports/S1_refute.md Read in full: no persistDefs/persistRun/persistClones anywhere in the method. Probe measures all three counters at 0 after a reconcile() that dropped a zombie run entry and renumbered a clone.
S1-F7 TimerDef.fromJson throws on a bad id, and every ?? fallback next to it is null-only MEDIUM MEDIUM CONFIRMED lib/engine/models.dart:66, and :22, :67, :68, :71, :72, :75, :134, :139, :156-158 findings/S1_engine.md · agent_reports/S1_refute.md Ten wrong-type inputs all throw, reproduced. models.dart:66 and CloneRef's :156-157 are the only unguarded casts; RunEntry.fromJson's status (:131-132) is the sole read that silently defaults instead of throwing.
S1-F8 Nothing bounds a duration: durationSec * 1000 overflows into a deadline in the past MEDIUM MEDIUM CONFIRMED lib/engine/engine.dart:177, :182, :232, :372-374, :386, :396 findings/S1_engine.md · agent_reports/S1_refute.md Reproduces endsAt < now and an immediate ring for durationSec = 9223372036854776. The reachable arm is the one S1 names: models.dart:68 defaults a missing durationSec to 0, and 0/negative both ring on the first tick.
S1-F9 A ringing entry with no nextVoiceAt passes reconcile() and is then silent forever MEDIUM MEDIUM CONFIRMED lib/engine/engine.dart:81-91 (the invariant block) and :333-335 findings/S1_engine.md · agent_reports/S1_refute.md :84 checks runningendsAt, :85 checks pausedremainingMs, and nothing checks ringing. Probe runs 1,000 beats and measures zero repeats.
S1-F10 The 1,200 ms alarm lead can be changed to 0 and the whole suite stays green MEDIUM MEDIUM CONFIRMED lib/engine/engine.dart:50, documented at :34-49 findings/S1_engine.md · proof/01_findings/S1_refute/remut_MUT6_alarmlead_zero.txt Mutation re-applied independently: +123: All tests passed!, EXIT_CODE=0. Five test sites reference alarmLeadMs, all of the form … - Engine.alarmLeadMs — the expectations move with the code.
MISS-1 (S1's refuter) reconcile() validates chain→def but never def→chain, so a chain that loses its flag rings at the end of step 1 MEDIUM — (new) New (refuter contribution) lib/engine/engine.dart:86-89; reachability hinge at lib/engine/models.dart:133 agent_reports/S1_refute.md §5 The structural invariant enforces the implication in one direction only. tick() then takes the single-timer branch at :330-331 and calls _fireAlarm at the end of step 1's duration: a twenty-one-minute dish rings after sixty seconds, with no step chime.
MISS-2 (S1's refuter) remainingMs is clamped on every live path and on none of the restore paths MEDIUM — (new) New (refuter contribution) lib/engine/engine.dart:85 (the invariant), :258 (the consumer); live clamps at :235, :247; hinge at lib/engine/models.dart:136 agent_reports/S1_refute.md §5 reconcile() accepts a paused entry whenever remainingMs is non-null and never looks at its sign; resumeTimer then computes a deadline before the resume instant and the dish rings on the next beat.
S2-F1 A wrong-TYPE stored value destroys the entire kitchen configuration silently: no backup, no banner, and the next save makes it permanent MEDIUM BLOCKER SEVERITY CHANGED (BLOCKER → MEDIUM) lib/engine/store.dart:34-41 and :93 findings/S2_persistence.md · agent_reports/S2_refute.md §2 Code and behaviour verified independently (timers=0, backup=null, banner={}, then saveDefs writes []). But the trigger is not production-reachable — nothing in the app writes a wrong-typed value to those keys. S2-F3 falls with it; S2-F9 does not, because its refuter found a reachable trigger needing no wrong type.
S2-F3 A wrong-TYPE cadence-zones-v1 marks the migration done without running it and then deletes the legacy tones MEDIUM HIGH SEVERITY CHANGED (HIGH → MEDIUM) lib/engine/store.dart:256-285; contradicted invariant at :260-261 findings/S2_persistence.md · agent_reports/S2_refute.md Mechanism confirmed and the probe reproduces (flag=true, zonesKeyStillThere=false). Same unreachable wrong-type trigger class as S2-F1, so the severity moves with it.
S2-F10 repairGeneratedPhrases deletes the operator's own sentence if it re-runs MEDIUM MEDIUM CONFIRMED (wording correction) lib/engine/store.dart:219-231, flag at :229, doc comment at :215-216 findings/S2_persistence.md · agent_reports/S2_refute.md Same "_guard never verifies" wording defect as S2-F2 — correct it. Interacts with S8-F2 (the generated phrase this migration writes is grammatically wrong in both languages).
S2-F11 A zone whose sound field has the wrong type is skipped in silence and its dishes fall back to Bell MEDIUM MEDIUM CONFIRMED lib/engine/store.dart:264-268, specifically :267 (if (id is String && s is String) tones[id] = s; with no else) findings/S2_persistence.md · agent_reports/S2_refute.md Probe reproduces (diagScopes=[]).
S2-F12 + S13-F6 The .corrupt preservation is write-only: nothing reads it, exports it, or restores from it; a second corruption is discarded; the siblings never expire and cannot be cleared MEDIUM MEDIUM (both) CONFIRMED lib/engine/store.dart:119-128; the only reader is :122 findings/S2_persistence.md · findings/S13_data.md · agent_reports/S2_refute.md Found twice — S2 from the persistence side, S13 from the data-retention side. S2's refuter re-ran the whole-tree grep and got the same three hits, all prose in comments (i18n.dart:141, engine/engine.dart:70, audio/alarm_volume.dart:42). S2R-M4 makes it worse: the .corrupt write itself is unverified and non-critical.
S2-F13 Every uncovered line in store.dart is a failure handler, and five of them are the write-failure paths MEDIUM MEDIUM CONFIRMED lib/engine/store.dart:133-147 and :174-180; the README claim at README.md:14 findings/S2_persistence.md · agent_reports/S2_refute.md lcov reproduced exactly on a pristine copy: store.dart 118/125 = 94.40 %, uncovered [47,126,137,142,145,176,178]; journal.dart 85/104 = 81.73 %; diagnostics.dart 19/22 = 86.36 %, uncovered [15,16,20]; total 858/1927 = 44.53 %. The refuter executed line 137 for the first time and the branch behaves as designed.
S2-F14 Deleting the migration's persistence step passes all 123 existing tests (mutation S2-M1) MEDIUM MEDIUM CONFIRMED — reproduced independently lib/engine/store.dart:280 findings/S2_persistence.md · agent_reports/S2_refute.md §4 Reproduced from scratch.
S2-F15 The journal header cannot distinguish two builds of the same version, which is the exact failure this commit set out to fix MEDIUM MEDIUM CONFIRMED lib/journal.dart:83, sourced from lib/main.dart:20 (const String kAppVersion = '0.4.12';); pubspec.yaml:5 is 0.4.12+18 findings/S2_persistence.md · agent_reports/S2_refute.md version_test.dart compares .split('+').first and its own comment states the omission verbatim.
S2-F16 Rotation is checked only at init, so a kiosk session that never restarts passes the 3 MiB cap MEDIUM MEDIUM CONFIRMED verdict / figures REFUTED twice lib/journal.dart:72; cap _maxBytes = 3 * 1024 * 1024 = 3,145,728 at :25 findings/S2_persistence.md · agent_reports/S2_refute.md §5 · agent_reports/S12_refute.md §1 The cap exists, is checked only at init, and the MEDIUM grade holds. S2's "34.1 days to the cap" is wrong. S2's refuter re-measured the byte sizes and got 20.7 days; S12's refuter reconciled the whole model against production emitters and got 13.0 days at 242,270 bytes/day. Replace every digit S2 published except the 167-byte session header and the 3,145,728-byte cap. Full reconciliation in CONTRADICTIONS.md §3.
S2-F17 Diag.log and DiagEntry.toString are dead in production: the in-RAM diagnostics the header advertises are read only by tests MEDIUM MEDIUM CONFIRMED lib/diagnostics.dart:9-22 findings/S2_persistence.md · agent_reports/S2_refute.md Independent greps: Diag.log has zero readers in lib/, two in test/ (store_test.dart:104, journal_test.dart:45); DiagEntry appears only inside diagnostics.dart; lcov shows 15,16,20 uncovered.
S2-F18 One failure report copies an unbounded error string into both the 50-entry buffer and the on-disk journal MEDIUM MEDIUM CONFIRMED lib/diagnostics.dart:29 and :37 findings/S2_persistence.md · agent_reports/S2_refute.md Independently reproduced: in-RAM entry length 200,000.
S2-C2 An unknown or absent run status is silently coerced to running MEDIUM MEDIUM CONFIRMED with a citation correction lib/engine/models.dart:132not :130-131 as S2 cited; the orElse: clause is on line 132. Reached from lib/engine/store.dart:73 findings/S2_persistence.md · agent_reports/S2_refute.md The coercion is real. Second correction: the outcome is not always "not dropped" — see the refuter's blast-radius row B6. S1's refuter independently confirmed :131-132 is the sole read that silently defaults.
S2R-M2 markCleanExit is fire-and-forget, so a clean shutdown reports itself as an OS kill MEDIUM — (new) New (refuter contribution) lib/journal.dart:186-190, called without await from lib/ui/home.dart:202 agent_reports/S2_refute.md §7 The death detector's whole value is that !clean && lastBeat != null (journal.dart:85) means the OS killed us. markCleanExit awaits a flush: true write before writing the flag, and is called unawaited from the AppLifecycleState.detached callback — the last moment before the process dies. Every death inside that window prints !! SESSION PRECEDENTE TUEE on the next boot.
S2R-M3 A load- critical banner can never be cleared for the life of the session MEDIUM — (new) New (refuter contribution) lib/diagnostics.dart:41-46 and its six call sites; raised at lib/engine/store.dart:120 agent_reports/S2_refute.md §7 Diag.clearCritical is called for backstop-init, backstop-schedule, backstop-exact, voice-init, audio-play and save-$key — never for a load- scope. Store.load runs once at home.dart:75, so nothing can retract it. S2's own probe measured this and filed no finding.
S2R-M4 The .corrupt backup write is itself unverified and non-critical, so the "never silently lost" guarantee can fail silently MEDIUM — (new) New (refuter contribution) lib/engine/store.dart:123; the guarantee is stated at :115-118; the guard's own doc is at :173 agent_reports/S2_refute.md §7 The write that delivers the guarantee uses the settings guard, documented one screen away as "comfort data — failure is logged, not critical". If it is refused, the only copy of the kitchen's configuration is gone and the failure is reported at the same level as a language preference.
S3-F10 + S10-F5 All timers share one ringtone player: a ringing tone cannot be stopped, and two dishes ringing at once cannot be told apart MEDIUM MEDIUM (S3) / HIGH (S10) SEVERITY CHANGED (S10 HIGH → MEDIUM); S10's sub-claim REFUTED lib/audio/audio.dart:19 (AudioPlayer? _ring), :62, :71 (await p.stop() before :73 play), :86-87 findings/S3_audio_alarms.md · findings/S10_product.md · agent_reports/S10_refute.md · agent_reports/S3_refute.md Found twice — S3 from the audio side, S10 from the product side. S10's refuter refuted the consequence: the two runs carry independent repeat schedules (engine.dart:283-284, :293-294, each keyed to its own RunEntry.voiceGap/nextVoiceAt), so the tones alternate, each truncated at the other's next repeat — degradation, not the total loss described. The voice channel also names each dish independently (home.dart:294-295). And the test S3 cites as proof of S3-F10 does not test it (S3R-F1).
S3-F11 Haptics: failures escape as unhandled async errors, and _canVibrate does not mean "has a vibrator" MEDIUM MEDIUM CONFIRMED lib/audio/audio.dart:49-53, :96-101, :105-109Vibration.vibrate unawaited and uncaught at :99 and :107 findings/S3_audio_alarms.md · agent_reports/S3_refute.md Confirmed by reading the block. Overlaps S5-F8's unawaited-futures inventory, which cites the same two lines.
S3-F12 A wedged TTS engine costs 12 s of silence per announcement and raises no operator banner MEDIUM MEDIUM CONFIRMED lib/audio/voice.dart:161-173; the 12 s timeout at :168, the non-critical voice-speak at :170 findings/S3_audio_alarms.md · agent_reports/S3_refute.md Verified. Distinct from S12-F6, which is the native-side channel-result leak on the same wedge.
S3-F13 The speech queue is unbounded and its only trim path cannot be tested MEDIUM MEDIUM CONFIRMED by S3's refuter; bound measured by S12 — see CONTRADICTIONS.md §8 lib/audio/voice.dart:26 (_queue), :36 (staleMs as a static const), :180-188 (_dropStale) findings/S3_audio_alarms.md · agent_reports/S3_refute.md · findings/S12_lifecycle.md §3.4 S3's refuter verified the unbounded _queue and the untestable static const staleMs. S12 measured 5,000 pending entries after 5,000 enqueues with a dead engine and concluded bounded, not a leak — because in production the bound is maxBatch × repeats ≈ 210 entries per 20-second window. Both are right about different things: the queue enforces no bound of its own; the engine's maxBatch supplies one.
S3-F14 + S6-F18 (title half) A background notification cannot tell the cook which batch is ready: _desired uses engine.labelFor but showNow uses raw t.name, and _nid is per-clone MEDIUM MEDIUM (S3) / LOW (S6, as part of a zero-padding finding) CONFIRMED; S6's LOW grade explicitly rejected by its refuter lib/alarm_backstop.dart:113 (engine.labelFor(t.id)) vs :259 (raw t.name); consumer lib/ui/home.dart:300; also :186 findings/S3_audio_alarms.md · findings/S6_dry_deadcode.md · agent_reports/S6_refute.md Found twice. S6's refuter: "a backgrounded rush stacks three notifications all reading ⏰ Fries. That is a behavioural defect, not a duplication, and it does not belong at LOW inside a zero-padding finding." Take S3's MEDIUM.
S3-F15 + S12-F5 SoundBox never releases its five AudioPlayers; SoundBox and VoiceBox have no disposal at all MEDIUM MEDIUM (both) PARTIALLY REFUTED — classification corrected lib/audio/audio.dart:10-116 (whole class), players created at :44-54; lib/ui/home.dart:235-243 (dispose, which does not release them) findings/S3_audio_alarms.md · findings/S12_lifecycle.md · agent_reports/S12_refute.md §2 Found twice. S12's refuter reproduced it (AudioPlayer create calls during boot: 5 / calls after unmount: []) and refuted the "LEAK" label: SoundBox.init() is called once, from _boot(); five players exist for the process lifetime and never grow. That is a missing dispose, not an accumulation, and S12's "6 undisposed on at least one reachable path" overstates it.
S3-F16 iOS speak can complete the wrong utterance's result, breaking one-announcement-at-a-time MEDIUM MEDIUM (STATIC ANALYSIS ONLY — never compiled or run) CONFIRMED (static analysis only) ios/Runner/AppDelegate.swift:157-175 and :186-192 findings/S3_audio_alarms.md · agent_reports/S3_refute.md §Part 5 The refuter re-derived the ordering argument from the source and confirmed it, and separately discarded a pendingSpeaks key-collision hazard as unreachable. No Xcode on this machine — neither agent compiled or ran this.
S3R-F4 S3's four mutation proofs do not meet R8 as written MEDIUM — (new) New (refuter contribution, R8 process defect) proof/01_findings/S3/mutations/M1_volume_floor.after.txt, M2_backstop_chain.after.txt, M3_audio_critical.after.txt, M4_voice_timeout.after.txt agent_reports/S3_refute.md §4 R8(c) and R8(d) are met; (a) and (b) are not — none of the four runs used --reporter=json, and all four were scoped with --plain-name to a single file, so the failing set was never compared against the whole suite. See EVIDENCE_INTEGRITY.md.
S3R-F5 R8(b) as written can never be satisfied by a testWidgets test, which puts 7 of S3's 40 tests permanently outside the rule MEDIUM — (new) New (refuter contribution, rule defect) AGENT_RULES.md R8 clause (b); affected artefact proof/01_findings/S3/tests/s3_volume_channel_test.dart (all 7 tests are testWidgets) agent_reports/S3_refute.md §4 flutter_test catches a TestFailure inside testWidgets through the Flutter error pipeline and re-reports it as a plain error, so the JSON reporter emits result: "error" with isFailure: false. This is a defect in the audit rule, not in the app — Phase 4 must amend R8(b) to accept isFailure: false widget-test errors, or it will reject valid mutation proofs.
S4-F05 + S12-F7 The 150 ms tick rebuilds the entire screen unconditionally, including when nothing is running; one tile's countdown rebuilds every tile MEDIUM MEDIUM (S4) / HIGH (S12) SEVERITY CHANGED (S12 HIGH → MEDIUM); S4 UNREFUTED lib/ui/home.dart:154-167, specifically the unguarded setState(() {}) at :166; :559 (now: DateTime.now() handed to every tile) findings/S4_ui.md · findings/S12_lifecycle.md · agent_reports/S12_refute.md §3.1 Found twice. S12's refuter reproduced 155 builds/tick, and measured it reaching layout (16 marks) and paint (2 marks) — net 1,766 µs/tick falling to 7.5 µs with the one-line guard if (mounted && engine.run.isNotEmpty) setState(() {}), a 235× reduction. HIGH is not supported: 1,766 µs is debug-JIT Dart with asserts on, on Apple Silicon, not release AOT on an ARM tablet, and there is no power measurement. The settling artefact is named: adb shell dumpsys batterystats before and after, one hour foreground, unplugged, report the delta in mAh.
S4-F06 The urgency colour is isoluminant across its first half and non-monotonic overall MEDIUM MEDIUM UNREFUTED — verdict provisional lib/ui/theme.dart:37-39 (anchors), :59-69 (fillFor) findings/S4_ui.md · proof/01_findings/S4/ S7's refuter independently verified the anchor values while auditing the theme_test.dart spec: fillFor(1.0) is 0xFF5CC79A (mint anchor) and fillFor(0.0) is 0xFFEC6A6A (red anchor), and both fillFor(0.35) and fillFor(0.15) return their anchors exactly because they mix with t = 0 at :66-67.
S4-F07 Five text/background pairs fail WCAG contrast, including the multi-step phase banner MEDIUM MEDIUM UNREFUTED — verdict provisional lib/ui/tile.dart:207 (C.muted idle countdown), :506 + :536 (banner on C.amber) findings/S4_ui.md · proof/01_findings/S4/
S4-F08 The app is invisible to a screen reader: zero semantic labels, zero button roles, and the LCD ghost digits are announced MEDIUM MEDIUM UNREFUTED — verdict provisional; corroborated by prior work Whole UI layer — grep -rn 'Semantics\|semanticLabel\|ExcludeSemantics\|tooltip' lib/ returns zero matches across 4,891 lines findings/S4_ui.md · research/01_prior_work.md §2.1 A1-6, §2.4 This is the largest single decision the Flutter line did not carry over from the v2 rewrite. Prior audit A1-6 (2026-07-19) filed it as MEDIUM and it is recorded STILL OPEN; A4 §14-15 documents the v2 work (real buttons, native dialogs, explicit move-earlier/move-later reorder controls) that was dropped. Same defect, third independent sighting.
S4-F09 MediaQuery.withNoTextScaling is a total opt-out, and it is right for the board and wrong for the dialogs MEDIUM MEDIUM UNREFUTED — verdict provisional lib/main.dart:48 (owned by S14, cited not judged), consumed by every text widget findings/S4_ui.md S7's refuter independently verified the line while auditing the main_test.dart spec: main.dart:48 is builder: (context, child) => MediaQuery.withNoTextScaling(child: child!).
S4-F10 A failed save is reported to the log and to nobody else; the branch that would warn is unreachable MEDIUM MEDIUM UNREFUTED — verdict provisional lib/ui/home.dart:443-450 (the failed-save path), :679-680 (the banner mapping) findings/S4_ui.md Same unreachable-banner mechanism as S5-F5 and S3R-F3, both of which were independently confirmed. Fix all three with the one else branch in _criticalBanner.
S4-F11 Every icon in the product is a text glyph that no bundled font contains MEDIUM MEDIUM UNREFUTED — verdict provisional lib/ui/tile.dart:619 (), :552 (✎ EDIT), lib/ui/header.dart:97 () findings/S4_ui.md Interacts with S6-F22 (uses-material-design: true ships an icon font for zero icons), which its refuter confirmed: grep -rn "Icon(\|Icons\.\|IconData\|IconButton" lib/ → zero hits.
S4-F12 The operator's only failure signal is 13.5 px tall, never clears, and is placed where it steals grid height MEDIUM MEDIUM UNREFUTED — verdict provisional lib/ui/home.dart:690-702 (banner box and text style), :565 (position in the column) findings/S4_ui.md The "never clears" half is independently confirmed for one scope class by S2R-M3 (a load- critical banner can never be cleared for the life of the session) and by S14's refuter for the wakelock banner (S14-F4).
S4-F14 (grid half) → merged into S6-F5; S4-F14 (tick half) → merged into S6-F2 The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI MEDIUM MEDIUM UNREFUTED as filed; both halves CONFIRMED via S6's refuter lib/ui/home.dart:515-521 vs :609-613; lib/engine/engine.dart:32 vs lib/ui/home.dart:154 findings/S4_ui.md · agent_reports/S6_refute.md Row retained so the ID is not lost. Implement against S6-F5 and S6-F2, both confirmed by S6's refuter with exact line ranges (_panUpdate :509-530, _tileIndexAt :609-615).
S4-F15 The UI layer's geometry lives in ~120 inline literals with no constants file MEDIUM MEDIUM UNREFUTED — verdict provisional lib/ui/tile.dart, lib/ui/header.dart, lib/ui/modals.dart, lib/ui/home.dart findings/S4_ui.md
S5-F5 A failure raised isCritical: true that cannot reach the operator banner MEDIUM MEDIUM CONFIRMED (count correction) lib/engine/store.dart:270 raises it; lib/ui/home.dart:675-689 drops it findings/S5_error_handling.md · agent_reports/S5_refute.md · proof/01_findings/S5_refute/R05_critical_scopes.txt Both halves verbatim-exact. Correction: there are 11 distinct critical scopes, not the 12 S5 claimsaudio-init, backstop-notif, backstop-init, backstop-exact, backstop-schedule, wakelock, audio-play, voice-init, load-$key, save-$key, migrate-zone-sound. Exactly one (migrate-zone-sound) matches none of the six startsWith prefixes. S3's refuter counted five unrenderable scopes when future fixes are included (S3R-F3). The else catch-all fix is correct and within R6.
S5-F6 Journal export failure leaves the Settings button silently reset MEDIUM MEDIUM CONFIRMED lib/ui/modals.dart:698-717 (the return at :702 is inside the try, so the catch at :712 cannot see it, while the finally at :714 resets _sending), with lib/journal.dart:233-236 findings/S5_error_handling.md · agent_reports/S5_refute.md Reachable without contrivance (a failing getTemporaryDirectory() or a failing copy). Two-line fix, correct, within R6.
S5-F7 The native side can never report an error, and Dart discards the booleans it does return MEDIUM MEDIUM PARTIALLY REFUTED MainActivity.kt:57-59, :79, and the eight catch (_: Exception) sites findings/S5_error_handling.md · agent_reports/S5_refute.md · proof/01_findings/S5_refute/R14_flutter_methodchannel_runtimecatch.txt Right half: zero result.error(...) calls in MainActivity.kt; the discarded booleans are real and production-reachable. Wrong half: the load-bearing sentence "every Dart-side .catchError on cadence/volume and cadence/tts is unreachable for native faults" is false. Flutter's embedding converts an uncaught RuntimeException from a channel handler into a PlatformException — verbatim MethodChannel.java:285-290 of the pinned 3.44.8 SDK — and MainActivity.kt has unguarded throw sites outside its try blocks at :45, :55, :83, :101-102, plus notImplemented() at :61/:109. So home.dart:211's .catchError and home.dart:227's catch are reachable. Do not write code on the refuted premise.
S5-F8 Unawaited futures with nowhere for a failure to go MEDIUM MEDIUM CONFIRMED (substance) / proof REFUTED lib/main.dart:33, lib/ui/home.dart:141-144 and :202, lib/audio/audio.dart:99, :107, :114, lib/audio/voice.dart:63, :146, :177, :201, lib/journal.dart:152 findings/S5_error_handling.md · agent_reports/S5_refute.md All eleven sites verified to exist. But the cited artifact does not demonstrate the claim: proof/01_findings/S5/06_unawaited_and_timeout.txt is a grep whose pattern misses 8 of the 11 sites; calling it "the full inventory" is an R2 defect. The inventory is also incomplete — it omits home.dart:86 (_boot(), which is R5-F1) and the Timer callbacks at home.dart:277, home.dart:387 and alarm_backstop.dart:147.
R5-F1 (S5's refuter) _boot() is launched unawaited and unguarded, and the 150 ms heartbeat is its last statement MEDIUM (the refuter records the consequence class as BLOCKER-grade) — (new) New (refuter contribution) lib/ui/home.dart:86 (the call), lib/ui/home.dart:154 (the ticker) agent_reports/S5_refute.md §4 initState fires _boot(); with no await, no .catchError and no enclosing try. _boot is a 60-line async method with four awaits, and the Timer.periodic that drives every countdown, alarm and repeat is created by its last statement. Any throw earlier in _boot skips the ticker permanently: the board renders, the tiles show their stored state, and nothing counts down. Same code region as S4-F04 (hang) and S12-F1 (unmount) — three failure modes, three fixes.
S6-F1 The release APK ships three CPU architectures; 61.5 % of it is dead weight on every device MEDIUM MEDIUM CONFIRMED (measurement) / headline framing REFUTED android/app/build.gradle.kts:30-36 — no splits/abiFilters; build command at README.md:25 findings/S6_dry_deadcode.md · agent_reports/S6_refute.md · proof/01_findings/S6_refute/apk_rebuild_independent.txt Rebuilt independently and reproduced to the byte: universal 53,629,091 B, arm64-only 20,623,249 B. The framing is wrong: minSdk = 24 and Google's own 64-bit requirements page permits dropping armeabi-v7a, but doing so drops 32-bit tablets — exactly the cheap hardware a restaurant buys. Prefer the App Bundle over abiFilters. Note S6R-F2: --obfuscate --split-debug-info removes a further 851,968 B and was never measured.
S6-F2 + S4-F14 (tick half) Engine.tickMs is dead while the heartbeat that it describes hard-codes the same number MEDIUM MEDIUM (both) CONFIRMED lib/engine/engine.dart:32 (declaration, zero readers) and lib/ui/home.dart:154 (the literal that should read it) findings/S6_dry_deadcode.md · findings/S4_ui.md · agent_reports/S6_refute.md Found twice. tickMs appears once in the whole tree. S7's refuter independently confirmed zero references in test/ and mutated 150→5000 with the suite still green.
S6-F4 (+ S6R-F4) The 5-second minimum duration is an invariant written at seven independent sites — plus an eighth the finding missed MEDIUM MEDIUM CONFIRMED, scope corrected lib/engine/engine.dart:368, :372-374; lib/ui/modals.dart:374, :388, :418, :524; lib/ui/modals.dart:436-437 (the fourth encoding, S6R-F4). lib/engine/models.dart:22 is NOT the invariant findings/S6_dry_deadcode.md · agent_reports/S6_refute.md Six sites enforce the floor; modals.dart:418 is the picker's snap-off-zero, which is the floor in practice because the seconds stepper moves in fives (:436-437). models.dart:22 is a JSON default, not a floor{'sec': 1} loads as 1. S6's "How to prove the fix" is also wrong: editor_layout_test.dart:76 asserts secs.first >= 5 where C.presets' smallest is 30 s, so re-pointing it at a constant set to 9 still passes. Write a new gate. Centralising without :436-437 makes the editor display one number and commit another.
S6-F5 + S4-F14 (grid half) The grid hit-test is implemented twice in the same file, once inline and once as a helper MEDIUM MEDIUM (both) CONFIRMED, exactly as described lib/ui/home.dart:509-530 (_panUpdate) and :609-615 (_tileIndexAt) findings/S6_dry_deadcode.md · findings/S4_ui.md · agent_reports/S6_refute.md Found twice. _tileIndexAt is already called from onPanStart at :581-583, 30 lines above. Proposed fix is the right shape and needs no signature change.
S6-F6 The EngineHost no-op implementation is copied into six test files MEDIUM MEDIUM CONFIRMED — "strongest duplication in the report" test/announcement_test.dart:23-42, test/i18n_defaults_test.dart:13-32, test/store_test.dart:12-31, test/backstop_test.dart:12-31, test/robustness_test.dart:13-34, test/engine_test.dart:7-34 findings/S6_dry_deadcode.md · agent_reports/S6_refute.md Three of the six are md5-identical (f2198392ae74ea9e5660de7aa3bfa9d2, 20 lines each); backstop_test differs only in now(). implements EngineHost forces all nine members in every copy, so "a tenth member breaks six files" is literally true.
S6-F9 RunStatus is re-encoded as bare strings throughout tile.dart MEDIUM MEDIUM PARTIALLY REFUTED — duplication confirmed, crash claim REFUTED twice over, enumeration wrong lib/ui/tile.dart:101-105 (_status) and eleven comparison sites at :110, :111, :182, :185, :192, :205, :207, :208, :257, :569; twelfth site at lib/ui/home.dart:124-133 findings/S6_dry_deadcode.md · agent_reports/S6_refute.md S6 claims a fourth enum value "would crash rather than fail to compile". It fails to compile (flutter analyze exit 1, non_exhaustive_switch_expression at home.dart:124), and with that error patched away the tile renders the fourth value without throwingr! is null-safe by construction. The twelfth site (S6R-F3) is the good one, an exhaustive switch with no default, and is the working example the fix should be modelled on.
S6-F15 + S11-F3 Three of the sixteen WAVs have no generator; the documented source of truth cannot reproduce them MEDIUM MEDIUM (both) CONFIRMED; S11's supporting inference INVALID tools/build_ringtones.py:1-238 (13 write/write_peak calls: 12 tones + res/raw/cadence_alarm.wav); the three unreproducible files are assets/audio/step.wav, assets/audio/click-up.wav, assets/audio/click-down.wav findings/S6_dry_deadcode.md · findings/S11_asset_licensing.md · agent_reports/S6_refute.md · agent_reports/S11_refute.md §5 Found twice — S6 from the reproducibility side, S11 from the licensing side. The measurements reproduce exactly and S11's refuter ran the flip test S11 named and failed to flip it. But S11's supporting inference — "proven synthetic → no third-party licence can attach" — is a non-sequitur. The conclusion is right for a reason S11 did not give; restate it before publishing.
S6-F16 + S9-F11 The backstop alarm sound is invisible to the shrinker, pinned by a single keep.xml rule with no regression test, and has already been stripped once MEDIUM MEDIUM (both) CONFIRMED with a three-state mutation proof android/app/src/main/res/raw/cadence_alarm.wav (142,928 bytes), android/app/src/main/res/raw/keep.xml:1-6, referenced from lib/alarm_backstop.dart:55 findings/S6_dry_deadcode.md · findings/S9_platform_config.md · agent_reports/S6_refute.md · agent_reports/S9_refute.md §Contrarian B Found twice. Both refuters confirmed. build.gradle.kts sets neither isMinifyEnabled nor isShrinkResources, so the shrinker is off today and keep.xml is belt-and-braces — it becomes load-bearing the moment anyone enables shrinking. Do not delete keep.xml. S9's refuter proved all three states (shrinker off / on without keep / on with keep).
S7-F7 Notification ids have no test: every timer may collapse onto one id MEDIUM MEDIUM CONFIRMED (not individually re-graded) lib/alarm_backstop.dart:67 findings/S7_tests.md · agent_reports/S7_refute.md S3's refuter independently mutated _nid (mutation B1) and the suite detected it — so the id scheme has some protection, but no test names it. Cross-reference S3-F14, where per-clone _nid is what stacks three identical notifications.
S7-F9 Nine of the ten fromJson fallback defaults are unprotected MEDIUM MEDIUM CONFIRMED lib/engine/models.dart:22 (×2), :67, :68, :72, :131-132, :134, :139, :158 findings/S7_tests.md · agent_reports/S7_refute.md §6 The refuter audited the proposed models_test.dart spec and found it implementable and killable — it kills mutations M18–M22 and M24–M28. legacyZoneId exists at models.dart:39, is read at :78, and is absent from toJson (:56-63), so spec test 3 is exactly right.
S7-F10 TimerDef.isChain's >= 2 boundary has no test MEDIUM MEDIUM CONFIRMED (not individually re-graded) lib/engine/models.dart:52 findings/S7_tests.md S1's refuter independently checked isChain at 0, 1 and ≥2 steps and confirmed a one-step def is not a chain.
S7-F11 test/engine_test.dart:288 names cloning but asserts on the parent MEDIUM MEDIUM CONFIRMED test/engine_test.dart:288-295 findings/S7_tests.md · agent_reports/S7_refute.md §10 Verified verbatim at source.
S7-F12 The grid geometry ratio and aspect tests are tautological MEDIUM MEDIUM CONFIRMED test/grid_layout_test.dart:14, :15, :21, :23, :38, :72 findings/S7_tests.md · agent_reports/S7_refute.md §10 Verified verbatim at source.
S7-F14 I18n.ttsLocale has no test MEDIUM MEDIUM CONFIRMED (not individually re-graded) lib/i18n.dart:166 findings/S7_tests.md
S7-F15 Store.lang validation is unprotected for a well-typed but unsupported language MEDIUM MEDIUM CONFIRMED and strengthened lib/engine/store.dart:158-161 findings/S7_tests.md · agent_reports/S7_refute.md §10 "Strengthened by execution counts S7 did not measure." Interacts with S8-F4 (language is chosen once and an install that never seeded is pinned to English forever), same lines.
S7-F19 One test's pass depends on a 50 ms wall-clock margin MEDIUM MEDIUM CONFIRMED (not individually re-graded); flakiness claim HELD test/backstop_test.dart:169; supporting test/voice_test.dart:59, :77, :94 findings/S7_tests.md · agent_reports/S7_refute.md §7 The refuter ran five further whole-suite runs (2 identical, 2 fresh random seeds, 1 fixed seed) — 123 pass, exit 0, every time. Thirteen independent runs now agree the suite is order-independent and flake-free today; the margin remains a latent flake source.
S7R-F1 The "19 of 123" headline row states as measured what §1b explicitly disclaims MEDIUM — (new) New (refuter contribution, reporting defect) findings/S7_tests.md §0 headline table, row "Distinct tests thereby individually validated" agent_reports/S7_refute.md §5 · proof/01_findings/S7_refute/rerun_comparison.txt The measured fact is "19 tests are individually pinned by a single-test assertion kill". The implication a reader takes is "104 tests are unproven". A bound S7 never computed says the opposite more strongly: 43 distinct tests of 123 went red under at least one mutation. Publish 43, not 19 alone.
S7R-F2 S7 never used the --reporter=json capture R8(a) requires; every one of its 57 runs used the expanded reporter MEDIUM — (new) New (refuter contribution, R8 process defect) All 57 of proof/01_findings/S7/mutations/M01..M57.txt agent_reports/S7_refute.md §5 The conclusions happen to be right — the refuter re-ran all 57 under json and agrees 57/57 — but S7's own records cannot demonstrate R8(a) or R8(b), because the fields are not in the capture. See EVIDENCE_INTEGRITY.md §3.
S7R-F3 Not one of S7's 69 recorded files carries a TREE_STATE stamp, and R8(d) is evidenced by a single end-of-campaign check MEDIUM — (new) New (refuter contribution, R12 proof integrity) Every .txt under proof/01_findings/S7/; proof/01_findings/S7/revert_clean_check.txt agent_reports/S7_refute.md §5, §8 R8(d) is evidenced by one aggregate git status --porcelain taken at 10:08:15Z, after all 57 runs (10:02–10:08) had finished. That proves the tree was clean at the end, not between runs. See EVIDENCE_INTEGRITY.md §1.
S7R-F6 Five of the thirteen test files were never reddened by any of the 57 mutations, and S7 does not report it MEDIUM — (new) New (refuter contribution) test/announcement_test.dart (267 lines, 13 tests), test/editor_layout_test.dart (79, 3 tests), test/i18n_defaults_test.dart (117, 6), test/source_hygiene_test.dart (25, 1), test/version_test.dart (29, 1) agent_reports/S7_refute.md §5 Five of thirteen files, carrying 24 of the 123 tests, never went red once across all 57 mutations. S7's manifest reports which mutations "land" in each file but never the inverse and more useful measure.
S8-F4 Language is chosen once, and an install that never seeded is pinned to English forever MEDIUM MEDIUM CONFIRMED lib/engine/store.dart:300-310 and :158-161 findings/S8_i18n.md · agent_reports/S8_refute.md Confirmed by S8's refuter; both line ranges also independently verified by S2's refuter (for S2-F9) and by S7 (for S7-F15).
S8-F5 The app's own name is inconsistent across the four places an operating system reads it MEDIUM MEDIUM CONFIRMED in substance; file:line REFUTED android/app/src/main/AndroidManifest.xml:19not :22, which is <activity; ios/Runner/Info.plist (CFBundleDisplayName, CFBundleName); web/manifest.json:2-3; lib/main.dart:44 findings/S8_i18n.md · agent_reports/S8_refute.md §2 S8-R5 grep -n 'android:label' android/app/src/main/AndroidManifest.xml returns 19: android:label="Cadence" and nothing else. This matters more than a normal citation slip: S8-F5 is the rename inventory, a 22-item checklist where items 3 and 7 are irreversible after first publish. The web/manifest.json:2-3 half is independently confirmed by S9-F12/S6-F21/S14-F7; the iOS half is adjacent to S9R-M03.
S8-F6 The Android notification channel is English-only, in the one recovery screen the app points at MEDIUM MEDIUM CONFIRMED in substance; EVIDENCE AND FIX REFUTED lib/alarm_backstop.dart:44-59not :46-49; the type is AndroidNotificationDetails with channelDescription:, not AndroidNotificationChannel with description:; consumed through static const NotificationDetails _details = NotificationDetails(android: _channel) at :60-61 findings/S8_i18n.md · agent_reports/S8_refute.md §2 S8-R4 S8-F6's "verbatim" block quotes a class that appears nowhere in lib/grep -rn 'AndroidNotificationChannel\|createNotificationChannel' lib/ exits 1. Its claim that "the channel is created once at :78-80 inside init()" is also false: :76-82 are permission requests and a Journal.log; flutter_local_notifications creates the channel implicitly from the details on the first notification. The conclusion survives (the two strings are hardcoded English and are the recovery screen the French banner sends a chef to) but the fix does not: making it language-aware means dropping both consts and threading an I18n through Backstop and every call site, not adding one construction in init().
S8-F7 Numbers, durations and the clock are formatted by hand, with two concrete defects MEDIUM MEDIUM CONFIRMED lib/ui/theme.dart:77-82; lib/ui/header.dart:143-148; lib/ui/modals.dart:637 findings/S8_i18n.md · agent_reports/S8_refute.md Confirmed. Overlaps S6-F18 (four independent copies of a two-digit zero-pad), whose refuter REFUTED the pad2 half as a library idiom rather than logic. S8's defect is the formatting semantics, not the duplication — the two findings survive independently. Also overlaps S4-F17.
S8-F8 A missing key renders its own identifier on screen MEDIUM LOW SEVERITY CHANGED (LOW → MEDIUM, raised); mechanism CONFIRMED lib/i18n.dart:143-145 findings/S8_i18n.md · agent_reports/S8_refute.md §1, §2 S8-R1 The refuter measured call(missingKeyXyz)="missingKeyXyz" and FR_MISSING_FALLS_BACK_TO_EN=false, and proved the raw identifier reaches the screen. S8 rated it LOW because "parity is perfect"; the refuter showed parity is not the guard — see S8-R1.
S8-R1 (S8's refuter) A key deleted from both locale maps renders raw on screen and the whole suite stays green MEDIUM — (new) New (refuter contribution) lib/i18n.dart:143; the guard under test at test/i18n_defaults_test.dart:38-45; render site lib/ui/modals.dart:683 agent_reports/S8_refute.md §2 The parity test compares the two maps against each other, so deleting a key from both leaves parity perfect and the suite green while the identifier renders on screen. This is why S8-F8 is not LOW.
S8-R2 (S8's refuter) The editor shows the operator the ungrammatical French phrase, live, next to a correct one MEDIUM — (new) New (refuter contribution) lib/ui/modals.dart:235-242, wired at :337; the correct string is at lib/i18n.dart:58 agent_reports/S8_refute.md §2 S8-F2 treats the readyPhrase grammar defect as something the operator hears. It is also something the operator sees, in the editor, immediately beside a correctly-worded string — which makes it a visible product-quality defect during a sales demo, not only an audio one.
S8-R4 (S8's refuter) S8-F6's evidence quotes a class that does not exist in the codebase, and its fix targets the wrong construct MEDIUM — (new) New (refuter contribution; changes the fix) lib/alarm_backstop.dart:44-59 agent_reports/S8_refute.md §2 Detail in the S8-F6 row above. Implement the refuter's fix, not S8's.
S9-F08 ITSAppUsesNonExemptEncryption is absent, forcing the export-compliance questionnaire on every single upload MEDIUM MEDIUM CONFIRMED ios/Runner/Info.plist:4-69 — key absent from the top-level <dict> findings/S9_platform_config.md · agent_reports/S9_refute.md The refuter proved the absence independently with its own Info.plist key-set diff.
S9-F09 + S13-F3 (+ Apple readiness C1) No PrivacyInfo.xcprivacy in the app bundle MEDIUM MEDIUM (S9) / BLOCKER (S13) / hard blocker C1 (Apple readiness) REFUTED — the manifest is NOT required. S13's BLOCKER refuted (its refuter re-graded it LOW); S9's MEDIUM confirmed. MEDIUM carried, for the undocumented-reason half only Repository-wide — no .xcprivacy file exists. S13 cited the calling code at lib/journal.dart:69-72, :170-171, :194-199, :226, :230 findings/S9_platform_config.md · findings/S13_data.md · store_readiness/02_apple_app_store.md C1 · agent_reports/S9_refute.md §Contrarian A · agent_reports/S13_refute.md §1 Two refuters settled this independently, and they converged. Both quoted Apple's operative sentence, which no stream quoted: "For each executable or dynamic library in an app that uses a required reason API, the bundle that includes the executable or dynamic library needs to include a privacy manifest file that reports the API." S9's refuter enumerated nine linked iOS plugins from .flutter-plugins-dependencies (S9's own table had missed package_info_plus 10.2.1, transitive via wakelock_plus) and read the Flutter 3.44.8 engine manifest out of the built artifact: FileTimestamp (0A2A.1, C617.1), SystemBootTime (35F9.1). S13's refuter went further and disassembled the release build: libapp.so (Cadence's own AOT Dart, every File.exists()/length()/copy()/writeAsString() in journal.dart) has 0 undefined dynamic symbols and no required-reason symbols; the stat family lives entirely in libflutter.so, and nm -u Flutter.framework/Flutter shows _fstat, _lstat, _stat. Phase 4 may add an empty manifest (NSPrivacyTracking = false, empty arrays) — it must NOT populate NSPrivacyAccessedAPITypes, because Apple binds you to what you declare. Full resolution in CONTRADICTIONS.md §1.
S13-F6 + S2-F12 → see the S2-F12 + S13-F6 row above Corrupt-value siblings persist forever with no expiry and no way to clear them MEDIUM MEDIUM CONFIRMED by S13's refuter as well as S2's lib/engine/store.dart:119-128 findings/S13_data.md · agent_reports/S13_refute.md Row retained so the S13 ID is not lost. Both streams' refuters confirmed the same defect at the same lines.
S13-R1 (S13's refuter) The no-network proof has no layer covering native (non-Dart) network paths, and two plugins have them MEDIUM — (new) New (refuter contribution) lib/audio/audio.dart:73; audioplayers_darwin-6.5.0/darwin/…/WrappedMediaPlayer.swift:165-185; audioplayers_android-5.3.0/…/source/UrlSource.kt agent_reports/S13_refute.md §3 S13's §3 layer 3 enumerates packages importing package:http and shows each import site is unreachable. That method cannot see a plugin that opens a connection from Swift or Kotlin, because such a plugin imports no Dart HTTP library at all. The no-network conclusion survives — it is safe for a store listing — but the proof does not cover the case, and a store filing must rest on a proof that does.
S13-R3 (S13's refuter) S13-F2's store-declaration rationale is wrong, and following it would over-declare on both stores MEDIUM — (new) New (refuter contribution) S13-F2 "Why it matters"; S13 §1 preamble agent_reports/S13_refute.md §3 · proof/03_market/captures/s813r_play_data_safety_expanded.txt Google's published guidance, verbatim: "Collect means transmitting data from your app off a user's device", and "Developers do not have to declare data access as collection if it occurs solely on the user's device", plus an FAQ covering this exact shape (a user's own upload to their own cloud account governed by that provider's terms is not the app's collection). S13 also mis-frames its personal-data table as the input to the Data Safety form and Apple nutrition labels; it is a GDPR-controller inventory, a different question with a different answer.
S10-F4 The "add a batch" chip sits in the slap zone of a ringing tile MEDIUM HIGH SEVERITY CHANGED (HIGH → MEDIUM) lib/ui/tile.dart:384 (showDup = widget.dupShow && !widget.editing), lib/ui/home.dart:624 (status != null && cnt < Engine.maxBatch, which does not exclude ringing) findings/S10_product.md · agent_reports/S10_refute.md The code fact is confirmed exactly as cited. The geometry argument is weaker than stated: on the reference 602×332 tile the chip measures ≈73×48 px including padding, sitting immediately right of the name at roughly one quarter down the tile, and Flutter resolves a touch to one point, not a palm footprint. The consequence is self-announcing — the mis-tap starts a pan and the original alarm keeps ringing — so the cook is told within a second.
S10-F6 After any outage the board understates how late a dish is, by the whole outage MEDIUM HIGH SEVERITY CHANGED (HIGH → MEDIUM); premise REFUTED lib/engine/engine.dart:279 (rangAt = n), lib/ui/tile.dart:199-201 (count-up from it); driftMs computed at engine.dart:278 and journaled at home.dart:288-292 but absent from TileView's parameters (tile.dart:12-34) findings/S10_product.md · agent_reports/S10_refute.md S10's premise is its own reading and the code says the opposite verbatim: // count-up baseline = the ACTUAL ring instant, not the deadline. That is a written decision and F6's proposed fix inverts it without refuting it. The honest residue, which stands: lateness is computed, journaled and never shown anywhere in the UI. Implement the residue, not the inversion.
S10-F7 Nothing on the board says which station or which cook a timer belongs to MEDIUM HIGH SEVERITY CHANGED (HIGH → MEDIUM); two sub-claims corrected lib/engine/models.dart:29-49 (the whole TimerDef surface — no station, owner or timestamp; legacyZoneId at :37-39, :78 is migration-only); lib/engine/store.dart:311-317 findings/S10_product.md · agent_reports/S10_refute.md §4 The gap is CONFIRMED. Correction to the history: what v0.4.11 deleted was not a colour dot — the removed widget printed z.name.toUpperCase() inside a coloured pill on every tile (git show 07ee62a -- lib/ui/tile.dart). Do not re-add a dot; the prior art is a text pill.
S10-F8 Correcting a mistimed start costs one tap per ten seconds, and is impossible once the dish rings MEDIUM MEDIUM CONFIRMED, one sub-claim REFUTED lib/ui/tile.dart:601-616, :569-570; lib/engine/engine.dart:228-241 findings/S10_product.md · agent_reports/S10_refute.md No repeat-on-hold exists anywhere. Sub-claim refuted: S10 says the alarm_backstop.dart:36-39 debounce now "defends against a burst the UI can no longer produce". Wrong — S10's own table says +5 min costs 30 taps, and 30 taps in a few seconds is exactly the burst the 300 ms debounce collapses. Only the word holding in that comment is stale; the debounce is still load-bearing — do not remove it.
S10-F9 The most urgent state on the board is the least visible one MEDIUM MEDIUM CONFIRMED lib/ui/theme.dart:16 and :25 are byte-identical Color(0xFFE7DED0); lib/ui/tile.dart:778-779 sweeps p * 2 * math.pi, so the coloured area → 0 as the deadline arrives findings/S10_product.md · agent_reports/S10_refute.md Confirmed, and the refuter notes S10 correctly names the artifact that would settle the remaining design question rather than hedging.
S10-F10 The batch cap is silent: the button just disappears MEDIUM MEDIUM CONFIRMED lib/engine/engine.dart:31, :194; lib/ui/home.dart:624 findings/S10_product.md · agent_reports/S10_refute.md
S10-F11 The app ships seeded with one specific restaurant's menu, removable only one dish at a time MEDIUM MEDIUM CONFIRMED lib/engine/store.dart:296, :326-343 findings/S10_product.md · agent_reports/S10_refute.md · research/01_prior_work.md §2.4 Verified verbatim, including const oven = 'Cascade', fryer = 'Chirp'. Prior work records this as a deliberate divergence from the v2 decision to seed general examples — the code comment calls it "The pilot kitchen's real timers, in service order". These are dish names, not a restaurant identifier, so source_hygiene_test.dart passes; the store-listing question is separate.
S10-F12 The operator has no in-app record of what happened; the only record is a French-only diagnostic file MEDIUM MEDIUM CONFIRMED lib/ui/modals.dart:673-689, :698-717; the hard-coded French journal lines at lib/ui/home.dart:358, :368, :383, :396, :408 are independent of store.lang findings/S10_product.md · agent_reports/S10_refute.md Confirmed. Related: S13-F1 (that same export carries operator free text and a device description off the device unredacted).
M2 (S10's refuter) A single-duration dish can only be given a duration whose seconds are a multiple of five MEDIUM — (new) New (refuter contribution) lib/ui/modals.dart:436-437, against :502-503 agent_reports/S10_refute.md §6 The seconds column steps (sec + 5) % 60 up and (sec + 55) % 60 down, and the six presets (theme.dart:52-54) are all multiples of five, with no text entry for a single-mode duration. 3:07 cannot be entered at all. A chained dish can: _stepRow gives every phase a free numeric field clamped only to 0-59. The two modes disagree about what a duration is. Same two lines as S6R-F4.
S11-F2 Two of the three font families ship with their embedded licence record stripped, and no record of where the binaries came from MEDIUM HIGH SEVERITY CHANGED (HIGH → MEDIUM); characterisation OVERSTATED assets/fonts/BigShouldersDisplay-{Medium,Bold,ExtraBold}.ttf and the three ChivoMono faces — six files findings/S11_asset_licensing.md · agent_reports/S11_refute.md §4 The stripping is real and reproduced. But upstream name ID 13 is a one-sentence notice plus a URL, not the OFL text — and name ID 14 carries that same URL and is already present in the bundled files. "The licence travels nowhere" is wrong. Correct the sentence before publishing.
S11-F4 The web target ships the Flutter logo as the product's own icon, with no BSD-3-Clause notice MEDIUM MEDIUM CONFIRMED and STRENGTHENED web/favicon.png, web/icons/Icon-192.png, web/icons/Icon-512.png, web/icons/Icon-maskable-192.png, web/icons/Icon-maskable-512.png findings/S11_asset_licensing.md · agent_reports/S11_refute.md §6 · agent_reports/S9_refute.md §M02 All five icons are now byte-proven, not three: the two maskable icons were fetched from flutter_template_images 5.0.0 (sha256 0120589a…) and are BIT-IDENTICAL; S11's "byte comparison unavailable" limitation was not real. The trademark question is answered definitively from Google's own guidelines, which S11 never fetched. S9's refuter reached the opposite conclusion about three of the five — see CONTRADICTIONS.md §4.
S11-F5 The application has no licence surface at all MEDIUM MEDIUM CONFIRMED lib/ (whole tree — no showLicensePage/LicenseRegistry call site exists); Settings dialog at lib/ui/modals.dart:616-693 findings/S11_asset_licensing.md · agent_reports/S11_refute.md §3.3 Verified from the Flutter SDK source at the pinned version, not from the API doc: LicenseRegistry/showLicensePage would surface nothing for these fonts even if called.
R-F2 (S11's refuter) S11's asset inventory omits 33 tracked binaries MEDIUM — (new) New (refuter contribution, coverage defect in the audit) android/app/src/main/res/{mipmap-*,drawable-*}/*.png (15), ios/Runner/Assets.xcassets/**/*.png (18) agent_reports/S11_refute.md §7 S11 states "Total binary assets in scope: 32" and its manifest lists 36 rows; the repository tracks 65 binary image/font/audio assets. The iOS omission concealed R-F1, a BLOCKER. Any Phase-4 licence file must be rebuilt from the 65, not the 32.
R-F3 (S11's refuter) The LICENSES draft reproduces the wrong Flutter copyright year for two of the five icons MEDIUM — (new) New (refuter contribution) findings/S11_LICENSES_draft.md:110 agent_reports/S11_refute.md §7 The draft reproduces Copyright 2014 The Flutter Authors (the flutter/flutter root LICENSE, which covers the three .copy.tmpl icons). The two maskable icons come from flutter_template_images 5.0.0, whose LICENSE reads Copyright 2013. BSD-3-Clause conditions binary redistribution on reproducing "the above copyright notice" — the notice attached to the work. findings/S11_LICENSES_draft.md is NOT APPROVED: 7 corrections required before Phase 4 commits it.
S12-F2 Delayed announcement timers are never held and outlive dispose() MEDIUM MEDIUM CONFIRMED lib/ui/home.dart:275-281, specifically :277 findings/S12_lifecycle.md · agent_reports/S12_refute.md §2 · proof/01_findings/S12_refute/repro_s12_w0_ticker_live.txt Reproduced red (EXIT_CODE=1). One of the three S12 findings confirmed as written: the code genuinely has no cancel path, the fix is four lines, the window is real. Latent, because _HomeScreenState.dispose() has exactly one production trigger at 03a176e and it destroys the isolate.
S12-F6 A wedged TTS utterance leaks a native channel result on both platforms MEDIUM MEDIUM CONFIRMED with a narrowed trigger android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:35, :143-165 (esp. :147, :160-162); ios/Runner/AppDelegate.swift:28, :173, :182, :188-189 findings/S12_lifecycle.md · agent_reports/S12_refute.md §2 The only LIVE leak of the six S12 claimed. Trigger narrowed: MainActivity.kt:150 calls speak(..., TextToSpeech.QUEUE_FLUSH, ...), so the next utterance interrupts the previous one and fires onStop(id, interrupted)completeSpeak, clearing the stale entry. The leak therefore requires an engine that accepts speak() with SUCCESS and then emits no UtteranceProgressListener callback at all — not merely "swallows a callback". Rewrite the trigger description.
S12-F8 Nothing in the app mitigates OLED burn-in on a static board held at full brightness for twelve hours MEDIUM MEDIUM PARTIALLY REFUTED — grep confirmed, premise refuted, and the proposed fix violates R6 lib/main.dart:28 (WakelockPlus.enable()), :33 findings/S12_lifecycle.md · agent_reports/S12_refute.md §3.2 The grep is confirmed (nothing mitigates burn-in; nothing names a panel). But the finding assumes an OLED panel and the audit establishes the panel nowhere — a 50/50 premise stated as certainty. The exact missing artefact: one field journal from the pilot tablet, whose fourth line is APPAREIL <manufacturer> <model> … (lib/journal.dart:119-120). The precise test: read that model number, look it up on the manufacturer's product page, record the display technology. OLED → MEDIUM stands. LCD → drops to LOW as image-persistence only. Separately: proposed fix (b) (a black overlay dismissed by touch) is a new end-user feature and violates R6, and fix (a) (shifting the grid origin ±3 px) moves every touch target and belongs to S4.
S12-F10 Three setState calls run after an await with no mounted guard MEDIUM MEDIUM PARTIALLY REFUTED — code fact confirmed, harm claim not upheld lib/ui/home.dart:448 and :456 (after await showTimerEditor at :421), and :491 findings/S12_lifecycle.md · agent_reports/S12_refute.md §6 The refuter re-verified every setState/await/mounted triple and confirmed all three sites are unguarded. It classes the finding among the seven "REFUTED in part — the code fact holds; the harm claim, the classification, or the severity does not" without stating a replacement grade, so MEDIUM is carried forward with that caveat attached. Same latency argument as the other lifecycle rows: dispose() has one production trigger and it destroys the isolate.
S12R-F1 Three per-tile trigger maps in _HomeScreenState grow without bound during a session, with no teardown required MEDIUM — (new) New (refuter contribution) lib/ui/home.dart:54-56 (declarations of _flash, _justOn, _spawn), :319, :361, :414, :415 (the only writes; zero removal sites) agent_reports/S12_refute.md §5 This refutes S12's own headline. S12 says the journal is "the one unbounded thing"; these three Map<String,int> are keyed by tile id, Engine.uid() (engine.dart:60-62) mints a fresh id per clone, and nothing ever removes an entry. They grow live, with no teardown required.
S12R-F4 Every S12 proof file stamps TREE_STATE: CLEAN, including the three recorded against a mutated or patched tree MEDIUM — (new) New (refuter contribution, R12 proof integrity) proof/01_findings/S12/*.txt — all 14; cause stated at findings/S12_lifecycle.md:542-543 agent_reports/S12_refute.md §5 S12 ran every command with CADENCE_REPO pointed at the pinned repo while working in a copy, so the stamp certifies the wrong tree. The mirror image of the S1/S2 defect: there the harness climbed out of the copy by accident; here it was pinned out deliberately. Both produce a TREE_STATE line that says nothing about the tree under test. See EVIDENCE_INTEGRITY.md §2.
S12R-F5 S12's mutation record does not meet R8 on three of its four conditions MEDIUM — (new) New (refuter contribution, R8 process defect) proof/01_findings/S12/mutation_stoptimer_keeps_clone.patch, mutation_soak_goes_red.txt; claim at findings/S12_lifecycle.md:641-659 agent_reports/S12_refute.md §5 Same class as S3R-F4, S7R-F2, and S5's two failed mutations.
S12R-F6 The soak's headline journal-growth figure is defended by no assertion MEDIUM — (new) New (refuter contribution, R8 process defect) proof/01_findings/S12/tests/s12_soak_test.dart:321-341 — the only journal assertion in SOAK A1 is expect(file.lengthSync(), greaterThan(0)) at line 341; the 27,585 B/h is printed at line 323 agent_reports/S12_refute.md §3.3, §5 The soak can go red, but not on the number it exists to produce. This is why the growth figure was wrong and nothing caught it.
S14-F4 The operator banner promised for a failed wakelock DOES exist and is reachable — but it can never clear, and a wakelock that silently fails to take effect produces no banner at all MEDIUM MEDIUM CONFIRMED — "the one I tried hardest to break" lib/main.dart:26-32, lib/ui/home.dart:670-707 findings/S14_entry_unowned.md · agent_reports/S14_refute.md §2.3 S14 proved the banner with a hand-made Diag.fail. The refuter drove the real main() with a failing wakelock backend and proved both residual defects by test rather than grep. Note S14R-M1 found an ERROR-severity type hole on lib/main.dart:31, the exact line this finding is about.
S14-F8 → reassigned to S9 .metadata registers web for template migration but not android or ios, so the two platforms that ship are excluded from every future Flutter template upgrade MEDIUM MEDIUM CONFIRMED; ownership corrected to S9 .metadata:13-20 findings/S14_entry_unowned.md · agent_reports/S14_refute.md §1, §3 S14R-M3 Diffed against a fresh template by the refuter. Reassigned per S14R-M3: S14 audited a 47-file scope deleted from the plan before Phase 1 launched.
S14R-M1 The analyser's strict type-checking modes were never measured, and turning them on surfaces six ERROR-severity type holes, one on the exact line S14 wrote F4 about MEDIUM — (new) New (refuter contribution) analysis_options.yaml:1-29 — the absence of an analyzer: block is the finding; the errors it hides include lib/main.dart:31 agent_reports/S14_refute.md §3 S14-F5 measured one dimension of the file (what sits under linter: rules:) and concluded from 75 lint infos that the analyser is "switched almost off". The other dimension — analyzer: language: controlling strict-casts, strict-inference, strict-raw-types, which are not lints — was never touched, and turning it on surfaces six ERROR-severity type holes.
S14R-M3 S14 audited a 47-file scope that was deleted from the plan before Phase 1 launched, so five of its findings belong to other streams and its partition reconciliation contradicts the partition tool MEDIUM — (new) New (refuter contribution, process defect) findings/S14_entry_unowned.md header and its git ls-files reconciliation section, against PLAN.md:175, CHECKLIST.md:50 and :67, tools/check_partition.py, research/04_partition.md:46-47 agent_reports/S14_refute.md §3 This is the authority for the five reassignments applied in this register (F6→S10, F7→S6, F8→S9, F9→S9, F11→S7). It changes what Phase 2 and Phase 4 must do, not what the app does. S14's own "144 files map to exactly one stream; S14 absorbs 47 including pubspec.yaml/pubspec.lock" claim is REFUTED by four independent artefacts.
S9R-M03 The app localises itself manually into French and English, but the iOS bundle declares English only MEDIUM — (new) New (refuter contribution) ios/Runner/Info.plist:4-69 (CFBundleLocalizations absent) and ios/Runner.xcodeproj/project.pbxproj:196-201 (developmentRegion = en) agent_reports/S9_refute.md §Part 6 lib/i18n.dart carries full fr and en string tables, lib/ui/modals.dart:626 offers the picker, and lib/engine/store.dart:293-294 starts a fresh install in French when the tablet is French. None of that is declared to iOS. Companion to S8-F1/S14R-M2 (the Flutter-side localisation gap) and to S8-F5 (the name is inconsistent across the four places an OS reads it).

MEDIUM count: 88 rows.


4. LOW

ID Title Final severity Original severity Verdict File:line (at 03a176e) Evidence path Refuter note
S1-F11 reorder is asymmetric: dragging forward drops after the target, dragging back drops before it LOW LOW CONFIRMED lib/engine/engine.dart:418-426, quoted lines :422-423 findings/S1_engine.md · agent_reports/S1_refute.md Probe measures [a,b,c] → reorder('a','c') → [b,c,a] and reorder('c','a') → [c,a,b]. test/engine_test.dart:335-339 asserts only the first direction.
S1-F12 spawnClone will start a batch of a dish that is not running LOW LOW CONFIRMED lib/engine/engine.dart:191-200 — parent check at :192-193, cap check at :194, no run check; startTimer at :198 runs regardless; contract at :189 findings/S1_engine.md · agent_reports/S1_refute.md Reproduced.
MISS-3 (S1's refuter) One user action issues two full persist cycles, and one beat issues one per firing dish LOW — (new) New (refuter contribution) lib/engine/engine.dart:212 and :414 (delete), :212 and :403 (save), :285 (per-entry in tick) agent_reports/S1_refute.md §5 Three dishes landing on the same 150 ms beat produce three full persist cycles.
S2-F20 models.dart points readers at a symbol that does not exist LOW LOW CONFIRMED lib/engine/models.dart:13 (_kZoneSoundMigration, never declared) findings/S2_persistence.md · agent_reports/S2_refute.md grep -rn "_kZoneSoundMigration" lib/ → single hit, the comment itself.
S3-F19 assetFor carries a rewrite no tone can reach LOW LOW CONFIRMED for in-set tones; contradicted for out-of-set tones by S6R-F1 lib/audio/audio.dart:83-84 findings/S3_audio_alarms.md · agent_reports/S3_refute.md · agent_reports/S6_refute.md S3's refuter verified assetFor :83-84. S6's refuter proved an out-of-set tone name does reach it, unvalidated, from the migration and from fromJson. Both are true of different input sets — see CONTRADICTIONS.md §7. Do not delete the rewrite branch until S6R-F1's validation is in place.
S4-F16 + S6-F3 Dead symbols in the UI layer: two dead colour constants in the palette LOW LOW (both) CONFIRMED lib/ui/theme.dart:23 (C.mint) and :29 (C.logoInk); also lib/ui/grid_layout.dart:42, :94 findings/S4_ui.md · findings/S6_dry_deadcode.md · agent_reports/S6_refute.md Found twice — S4 handed it to S6, and S6's refuter proved it with an automated sweep over 123 public declarations: one line each, the declaration; raw values 0FA96A/F5F1E8 appear nowhere else, including Kotlin, Swift, XML and test/; no dart:mirrors/noSuchMethod/Function.apply anywhere, so no dynamic lookup is possible.
S4-F17 A timer left ringing overnight prints a five-digit minute counter that the tile was never sized for LOW LOW UNREFUTED — verdict provisional lib/ui/theme.dart:77-82 (fmtTime / fmtUp), consumed at lib/ui/tile.dart:200 findings/S4_ui.md S7's refuter independently verified the formatter's behaviour at boundaries while auditing the theme_test.dart spec (fmtTime(-5) is '0:00'). Overlaps S8-F7.
S5-F10 Two empty catch blocks LOW LOW CONFIRMED (fix reasoning corrected) lib/journal.dart:128, :231 findings/S5_error_handling.md · agent_reports/S5_refute.md Verbatim-exact. :231 genuinely is best-effort after the primary copy succeeded at :226. :128 genuinely degrades device to Platform.operatingSystem, and that string is stamped at journal.dart:84, shown at modals.dart:683, used for the export filename at journal.dart:215-223 and the share subject at modals.dart:707 — all four confirmed. The fix's reasoning is wrong in detail (_file is assigned at :73, before device at :75, so Journal.log would be admitted by the :134 guard) but the recommended remedy is fine anyway.
S6-F7 The 7000 ms first voice gap is written three times, in two files that cannot see each other LOW MEDIUM SEVERITY CHANGED (MEDIUM → LOW); consequence REFUTED lib/engine/engine.dart:52, lib/engine/models.dart:114, :139 findings/S6_dry_deadcode.md · agent_reports/S6_refute.md · proof/01_findings/S6_refute/mutation_firstvoicegap_3000.txt The three literals exist. The stated mechanism is false: RunEntry.toJson writes 'voiceGap' unconditionally (models.dart:126), so a restored entry carries its stored value, and _ring overwrites voiceGap = firstVoiceGapMs on every ring (engine.dart:283). Its "How to prove the fix" is also wrong: the refuter mutated 7000→3000 and exactly one test went red — test/engine_test.dart:185 — while test/robustness_test.dart:291, the test S6 names, passed. That line's 7000 is an injected freeze duration, not a voice gap.
S6-F8 The running and paused remaining-time computation is written twice in tile.dart LOW LOW CONFIRMED lib/ui/tile.dart:185-198 findings/S6_dry_deadcode.md · agent_reports/S6_refute.md Four of six lines identical, including the t.steps! unwrap and the clamp.
S6-F10 Four styling duplications inside modals.dart LOW LOW PARTIALLY REFUTED — 3 of 4 confirmed lib/ui/modals.dart:253-258 vs :338-343 (byte-identical, CONFIRMED); :482-492 vs :535-545 (style blocks byte-identical, decorations differ by one number, CONFIRMED); _dashedAdd drawing BorderStyle.solid (CONFIRMED, a name that lies); :54-62 vs :719-727 REFUTED findings/S6_dry_deadcode.md · agent_reports/S6_refute.md _fieldLabel and _settingLabel share no value at all — size 10.9 vs 14.7, tracking 2.2 vs 2.3, colour muted vs text, gap 8 vs 12. Only the six-line shape matches. S6's proposed merge gives the function four optional parameters that all three settings call sites must override — more code and a worse API than two honest functions. Do not implement (a).
S6-F11 The tile drop shadow and the corner-radius formula are each written twice in tile.dart LOW LOW CONFIRMED lib/ui/tile.dart:441-446 vs :696-701 (shadow); :438-439 vs :598 (radius formula); :218 vs :798 (the literal 20) findings/S6_dry_deadcode.md · agent_reports/S6_refute.md Shadow blocks byte-identical (0x291C211C, blur 4, offset 0,1). math.min(10, 2.6*ch) vs math.min(10.0, 2.6*ch). BorderRadius.circular(20) vs Radius.circular(20 - inset) is a genuine cross-class coupling: the edit-mode outline must track the card's corner.
S6-F12 The widget-test "open a modal" harness is copied four times, the viewport override twice LOW LOW CONFIRMED test/announcement_test.dart:200-222 and :189-197; test/editor_layout_test.dart:22-34 and :41-47; test/volume_test.dart:137-158 and :169-183 findings/S6_dry_deadcode.md · agent_reports/S6_refute.md Four MaterialApp → Builder → TextButton(Text('open')) scaffolds; two 5-line viewport blocks differing only in Size.
S6-F13 The duration-preset label is duplicated across the production/test boundary LOW LOW CONFIRMED lib/ui/modals.dart:302-304 and test/editor_layout_test.dart:17-20 findings/S6_dry_deadcode.md · agent_reports/S6_refute.md The test's own comment admits the coupling. A test that reimplements the expression cannot detect a change in it. S7's refuter separately mutated the production expression and found both variants go red, so the helper "fails closed" — the duplication is a maintainability defect, not a hole.
S6-F14 ChivoMono-Medium.ttf is declared, bundled and never selected LOW LOW CONFIRMED — and now proven pubspec.yaml:61-62; file assets/fonts/ChivoMono-Medium.ttf findings/S6_dry_deadcode.md · agent_reports/S6_refute.md The refuter ran the experiment S6 only proposed: requested mono weights across the editor (both modes), the settings dialog, the tile in all five status states and the header are exactly {w400, w700}, and removing the Medium face leaves w400 and w700 rasterising to byte-identical pixels. Safe to delete.
S6-F17 The alarm-acknowledgement journal line is written twice in home.dart LOW LOW CONFIRMED lib/ui/home.dart:366-371 and :656-661 findings/S6_dry_deadcode.md · agent_reports/S6_refute.md :366-371 measures against now(), :656-661 against nowMs (the last build's timestamp, up to one 150 ms tick stale). Same headline metric, two clocks — that is a correctness difference, not only a duplication.
S6-F18 (pad2 half) Four independent copies of a two-digit zero-pad LOW LOW REFUTED lib/journal.dart:57 (p), :62 (_day), :222-223; lib/ui/header.dart:145, :147; lib/ui/modals.dart:399, :502, :704; lib/ui/theme.dart:79 findings/S6_dry_deadcode.md · agent_reports/S6_refute.md n.toString().padLeft(2, '0') is a library idiom, not logic. No change to any one of the ten sites would ever have to be made at the others — padding an hour in a filename and padding a minute on a clock face are independent. S6's own fix needs two helpers (pad2 in theme.dart plus Journal._pad2, because journal.dart must not import UI), i.e. it replaces ten copies with two copies plus a layering rule. Do not implement. The title half of S6-F18 is CONFIRMED and lives in the S3-F14 row.
S6-F19 + S14-F6 (→S10) + S2-F19 README.md documents the v0.1 architecture and contradicts the shipped app, including on the app's single most important safety property LOW LOW (S6) / MEDIUM (S14) / LOW (S2) CONFIRMED, and understated README.md:1-38 (dated "Last update: 2026-07-22"; pinned commit is v0.4.12); README.md:30-34 (the safety-property contradiction); README.md:13-14 (4 keys vs the 9 static const _k declarations in lib/engine/store.dart:15-24 plus lib/journal.dart:23-24) findings/S6_dry_deadcode.md · findings/S14_entry_unowned.md · findings/S2_persistence.md · agent_reports/S6_refute.md · agent_reports/S14_refute.md Found three times. S6's refuter verified all five contradictions verbatim and found a sixth (the key count, which is S2-F19). S14's refuter confirmed it independently by re-running git log and the key/test counts, and reassigned ownership to S10. The final grade takes S6's LOW; S14's MEDIUM rested on the safety-property contradiction at README.md:30-34, which is the sentence Phase 4 must fix first.
S6-F20 + S14-F5 analysis_options.yaml is the untouched Flutter template: it carries an empty linter: rules: block, and flutter analyze reporting 0 issues measures almost nothing MEDIUM LOW (S6) / MEDIUM (S14) CONFIRMED — exactly 75 issues analysis_options.yaml:12-25 (the empty block); :1-29 (the whole file) findings/S6_dry_deadcode.md · findings/S14_entry_unowned.md · agent_reports/S6_refute.md · agent_reports/S14_refute.md §2.5 Found twice. S14's refuter reproduced the 75-issue figure twice — once with S14's saved candidate file and once with only the six named rules. S6's refuter read the block in full and confirmed it is the unmodified template. Take the MEDIUM. And add S14R-M1: the analyzer: language: dimension was never measured and hides six ERROR-severity type holes.
S6-F21 + S9-F12 + S14-F7 (→S6) The web/ target is 7 files of unmodified stock Flutter template, shipping "A new Flutter project." and Flutter-blue branding, for a platform the product does not target MEDIUM LOW (S6) / MEDIUM (S9) / MEDIUM (S14) CONFIRMED — delete it, nothing in the build objects web/index.html:21, :26, :32; web/manifest.json:2-9; web/favicon.png; web/icons/* (4 files) findings/S6_dry_deadcode.md · findings/S9_platform_config.md · findings/S14_entry_unowned.md · agent_reports/S6_refute.md · agent_reports/S14_refute.md §2.6 Found three times, from the dead-code, platform-config and app-entry sides, and the prior audit's A1-5/A1-R6 make it four. S6's refuter: git log -- web/ returns exactly one commit, 22902e0, the v0.2.0 import. S14's refuter went furthest and proved the deletion is safe: 7/7 byte-identical to a fresh template, zero references, and after deletion flutter analyze = 0, suite = 123/123, flutter build apk --release = exit 0. Deleting web/ also closes S11-F4 and S9R-M02.
S6-F22 uses-material-design: true ships an icon font for zero icons LOW LOW CONFIRMED pubspec.yaml:42 findings/S6_dry_deadcode.md · agent_reports/S6_refute.md grep -rn "Icon(\|Icons\.\|IconData\|IconButton" lib/ → zero hits. The only Material widget that renders is Slider (modals.dart:655), which draws no icon.
S6R-F2 --obfuscate --split-debug-info removes 851,968 bytes and was never measured LOW — (new) New (refuter contribution) README.md:25 (the documented build command), android/app/build.gradle.kts:30-36 agent_reports/S6_refute.md · proof/01_findings/S6_refute/apk_obfuscated_split_debug_info.txt The Dart AOT snapshot ships with its symbol table intact. The flag strips it into a side file uploaded to Play for crash de-obfuscation. Subtractive, changes no behaviour, needs no product decision — unlike the 1,664,814-byte audio saving S6 reported, which requires re-validating every tone by ear.
S6R-F3 lib/ui/home.dart:124 is a twelfth RunStatus decision site and S6's map has no entry for it LOW — (new) New (refuter contribution) lib/ui/home.dart:124-133 agent_reports/S6_refute.md The twelfth site is the good one — an exhaustive switch expression with no default. It is the reason S6-F9's proof protocol is wrong and the working example the fix should be modelled on. Omitting it makes the codebase look uniformly worse than it is.
S6R-F4 The picker's five-second granularity is a fourth encoding of the 5-second floor LOW — (new) New (refuter contribution) lib/ui/modals.dart:436-437 agent_reports/S6_refute.md The seconds stepper moves in fives, so the smallest non-zero value the picker can express is five seconds. Centralise the floor without these two lines and the picker keeps offering 0:05 under a 10-second floor — the editor displays one number and commits another. Same two lines as M2.
S7-F17 Three tests silently depend on the process working directory LOW LOW CONFIRMED (not individually re-graded) test/source_hygiene_test.dart:11, test/version_test.dart:16 findings/S7_tests.md
S7-F18 Global static state is reset inconsistently across test files LOW LOW CONFIRMED (not individually re-graded) test/announcement_test.dart, test/editor_layout_test.dart findings/S7_tests.md · agent_reports/S7_refute.md §7 The refuter's 13 independent runs found no order dependence today, which bounds the risk without removing it.
S7R-F7 The logo_test.dart specification claims a guard its own assertion cannot deliver LOW — (new) New (refuter contribution) findings/S7_tests.md §4.6; subject lib/ui/logo.dart:12-16, pubspec.yaml agent_reports/S7_refute.md §5, §6 The spec asserts AssetImage.assetName == 'assets/logo/mark_white.png' and says this "catches the asset being dropped from pubspec.yaml". It cannotassetName is simply the string literal handed to Image.asset, identical whether or not the bundle declares the asset. The test is falsifiable but the failure mode it is named for would sail through. Rewrite the spec before implementing.
S8-R3 (S8's refuter) The migration's own comment states an invariant the migration violates LOW — (new) New (refuter contribution) lib/engine/store.dart:213-214 agent_reports/S8_refute.md §2 The doc comment on repairGeneratedPhrases claims it "changes NOTHING about what the app says today (the same words are regenerated)". Companion to S2-F10, which its own refuter confirmed: the migration does delete the operator's own sentence if it re-runs. Two independent refuters now contradict the same comment.
S8-R5 (S8's refuter) Three wrong file:line citations and five wrong line counts, all contradicting the shared code map LOW — (new) New (refuter contribution) S8-F5 "Location"; S8 §8 coverage manifest agent_reports/S8_refute.md §2 AndroidManifest.xml is 79 lines (S8 says 74), ios/Runner/Info.plist 70 (S8 says 78), web/index.html 46 (38), web/manifest.json 35 (33), pubspec.yaml 68 (69). Every one of these is correct in research/00_code_map.md:1641,1644,1647,1649,1650, the shared map every stream was told to use rather than re-derive. Every Dart line count in S8's manifest is right; only the non-Dart ones are wrong, which is consistent with their not having been measured. Note S13-R5 and S3's manifest carry the same class of slip on the same two files.
S8-R6 (S8's refuter) "32 hardcoded user-visible strings" counts glyphs, operator text and unreachable fallbacks LOW — (new) New (refuter contribution) S8 §3 and the summary table agent_reports/S8_refute.md §2 The refuter opened all 39 cited sites: every literal S8 lists is present at the line it gives, and no row is fabricated. But the headline count conflates three categories, and only ~15 are translation defects. Publish 15, not 32.
S13-R4 (S13's refuter) The permission enumeration is wrong in both directions LOW — (new) New (refuter contribution) S13 §3 layer 2 agent_reports/S13_refute.md §3 · proof/01_findings/S13_refute/apk_manifest_decoded.txt Decoded with apkanalyzer manifest print rather than a byte scan: eight android.permission.* requests plus one custom dev.sergemio.cadence.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION. DUMP is not a request — it is android:permission="android.permission.DUMP" guarding the androidx.profileinstaller ProfileInstallReceiver. S13 also flattens SCHEDULE_EXACT_ALARM, which ships android:maxSdkVersion="32". This is the artifact a restaurant's IT person reads on the Play listing; it has to match permission for permission.
S13-R5 (S13's refuter) Two wrong file:lines, two wrong line counts, and an internal 13-vs-14 contradiction LOW — (new) New (refuter contribution) S13-F2 "Location" (:20-23, actual :18-21); S13 §7 coverage manifest (AndroidManifest.xml 79 not 74, Info.plist 70 not 78); §1 vs §6 (14 rows vs "the 13 items in §1") agent_reports/S13_refute.md §3 Both line counts are correct in research/00_code_map.md:1644,1647. The count should read "13 items on a current install, 14 including the legacy zones key that the v0.4.11 migration removes at store.dart:283".
S9-F13 Stale TODO above a correct application id invites an irreversible change LOW LOW CONFIRMED android/app/build.gradle.kts:20-21 findings/S9_platform_config.md · agent_reports/S9_refute.md An application id cannot be changed after first publication. Delete the TODO.
S9-F14 + S14-F11 (→S7) ios/RunnerTests/RunnerTests.swift is a compiled test target whose single test asserts nothing, wired into the shared scheme's test action LOW LOW (both) CONFIRMED ios/RunnerTests/RunnerTests.swift:7-10; 19 RunnerTests references in ios/Runner.xcodeproj/project.pbxproj findings/S9_platform_config.md · findings/S14_entry_unowned.md · agent_reports/S9_refute.md · agent_reports/S14_refute.md Found twice. S14's refuter confirmed the file is STOCK-TEMPLATE and counted the 19 project references; ownership reassigned to S7 per S14R-M3.
S9-F15 org.gradle.jvmargs demands 12 GB of JVM memory LOW LOW CONFIRMED android/gradle.properties:1 findings/S9_platform_config.md · agent_reports/S9_refute.md
S9-F16 The two OEM quick-boot actions on the boot receiver cannot be delivered to a non-exported receiver LOW LOW PARTIALLY REFUTED — facts confirmed, causal claim and proposed fix REFUTED android/app/src/main/AndroidManifest.xml:48-56 findings/S9_platform_config.md · agent_reports/S9_refute.md §Part 5 The facts hold; the reason S9 gives for them and the fix it proposes are both wrong. Do not implement S9-F16's fix as written — re-derive it from the refuter's Part 5 against https://developer.android.com/guide/topics/manifest/receiver-element.
S9R-M01 + S14-F9 (→S9) The iOS launch image is the untouched Flutter template on both platforms, and S9's own manifest records the opposite LOW — (new, S9R) / LOW (S14) New (refuter contribution) + CONFIRMED (S14-F9) ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png, LaunchImage@2x.png, LaunchImage@3x.png; android/app/src/main/res/drawable/launch_background.xml:4 agent_reports/S9_refute.md §Part 6 · findings/S14_entry_unowned.md · agent_reports/S14_refute.md Found twice, and one of the two sightings corrects a false negative in the audit itself. S9's coverage manifest claimed the three iOS launch images "all three differ, so the launch image was customised". Both statements are false — S9 compared against the wrong template location; measured against flutter_template_images-5.0.0 all three are byte-identical, and all three are 1×1 pixel images. S14's refuter independently found all six launch files STOCK-TEMPLATE and noted the three iOS PNGs share one SHA-256 at 68 bytes each.
S9R-M02 The two maskable web icons are still the Flutter logo, while the other three web icons were replaced LOW — (new) New (refuter contribution) — partly contradicted by S11's refuter web/icons/Icon-maskable-192.png and web/icons/Icon-maskable-512.png; consumers at web/manifest.json:22-33 agent_reports/S9_refute.md §Part 6 · agent_reports/S11_refute.md §6.1 Both refuters agree the two maskable icons are byte-identical Flutter template. They disagree about the other three — see CONTRADICTIONS.md §4. The finding closes either way if web/ is deleted (S6-F21 merged row).
S10-F13 Below 150 px of tile width the app removes its own touch-target floor LOW LOW CONFIRMED, and understated lib/ui/tile.dart:589-597 (S10 cited :565-597) sets minH = 0 in the bottom tier; button geometry at :678 findings/S10_product.md · agent_reports/S10_refute.md Geometry replicated independently: 800×540 crosses 150 px at n=21 (tile 127×108) and 1280×740 at n=49 (tile 136×116); the pinned 4-dish case reproduces exactly (cols 2, tileW 602, rowH 332, gap 21, pad 27). S10's "roughly 37 px" is optimistic: ±10 s measures 35 px and the ✕ 31.6 px including hit padding. Corroborates S4-F03.
M3 (S10's refuter) The dish name is silently capped at 24 characters with the counter switched off LOW — (new) New (refuter contribution) lib/ui/modals.dart:251-252 (maxLength: 24 with .copyWith(counterText: '')) agent_reports/S10_refute.md §6 The field stops accepting keystrokes at 24 characters and shows nothing to say why. The cap is not a layout constraint — the tile deliberately auto-shrinks long names rather than truncating (tile.dart:393-395, "un nom coupé ne sert à rien en cuisine"), so the display would have absorbed a longer name. Complements S4-F02 (the shrink has no floor).
S11-F6 Big Shoulders Display has been retired from Google Fonts, so the bundled version cannot be re-fetched from the family page LOW LOW CONFIRMED pubspec.yaml:48-56; assets/fonts/BigShouldersDisplay-*.ttf findings/S11_asset_licensing.md · agent_reports/S11_refute.md §2.1 ofl/bigshouldersdisplay still carries the family and the licence; the variable master re-instances to the bundled binaries. Provenance is recoverable.
R-F4 (S11's refuter) Citation defects in S11: three pubspec.yaml line references LOW — (new) New (refuter contribution) S11 cites pubspec.yaml:33-34 for the icon-origin comment (used 4×) — actual :31-32; S11 cites :36 for adaptive_icon_background — actual :37 agent_reports/S11_refute.md §7 Full cat -n pubspec.yaml (68 lines) recorded. Correct before publishing; pubspec.yaml:33 is flutter_launcher_icons: and :34 is android: true.
R-F5 (S11's refuter) The NOTICES character count is method-dependent LOW — (new) New (refuter contribution) NOTICES — S11 and the draft both publish 1,381,653; reading the same bytes without newline translation gives 1,381,705 agent_reports/S11_refute.md §7 The file contains exactly 52 CRLF pairs; S11 read it in text mode, which collapsed them. Neither figure is wrong, but a digit-bearing claim under R1 must say which measurement it is.
R-F6 (S11's refuter) assets/icon/*.png are build inputs, not shipped bundle assets LOW — (new) New (refuter contribution) pubspec.yaml:44-46 lists only assets/audio/ and assets/logo/ agent_reports/S11_refute.md §7 The three assets/icon/ PNGs are inputs to flutter_launcher_icons and reach the APK only as Android resources. No licence consequence — they are author-original — but the distinction matters for any claim about what is "distributed".
R-F7 (S11's refuter) DSEG's name ID 0 is not a copyright statement LOW — (new) New (refuter contribution) assets/fonts/DSEG7Classic-Bold.ttf, name ID 0 (Created by Keshikan\nwith FontForge 2.0…); the copyright statement is in ID 13 agent_reports/S11_refute.md §7 S11 §2.4 asserts "All seven binaries … name a copyright holder". The conclusion holds; the supporting statement does not, per-record.
S12-F1 Unmounting while _boot() is awaiting orphans the 150 ms ticker permanently LOW (today) — HIGH the day a second route exists HIGH SEVERITY CHANGED (HIGH → LOW) lib/ui/home.dart:154 (creation; the reproduction binding names home.dart:154:21), :236-243 (dispose), :136, :141, :148, :149 (the awaits it sits behind) findings/S12_lifecycle.md · agent_reports/S12_refute.md §2 · proof/01_findings/S12_refute/repro_s12_w7_boot_race.txt Reproduced red (EXIT_CODE=1). HIGH rested on "two ticks running concurrently … a battery that drains twice as fast", and S12's own grep proves that cannot happen at this commit: one HomeScreen construction site (main.dart:55), one route, no full-screen push, showDialog pushes over without disposing, and AndroidManifest.xml:31's configChanges means no ordinary configuration change recreates the activity. _HomeScreenState.dispose() has exactly one production trigger and it destroys the isolate the orphan lives in.
S12-F3 Backstop owns a 300 ms timer but has no disposal, and arms an OS alarm after teardown LOW MEDIUM REFUTED on harm lib/alarm_backstop.dart:41 (field), :146-147 (creation); no disposal member exists findings/S12_lifecycle.md · agent_reports/S12_refute.md §2.1 The missing disposal member is confirmed. The harm claim is backwards: the alarm armed after teardown lands at the operator's new deadline for a timer that is still running and already persisted — the safety net doing its job at the moment it matters most. alarm_backstop.dart:125-126 warns about arming for a stopped timer; this is not that case, and Backstop.init() calls _plugin.cancelAll() at :89 on the next launch. Its proposed fix actively harms the product — see S12R-F3.
S12-F4 Journal.init() overwrites its two periodic timers without cancelling the previous pair LOW MEDIUM SEVERITY CHANGED (MEDIUM → LOW) lib/journal.dart:106-107; the only cancel site is :241-242, inside a @visibleForTesting member findings/S12_lifecycle.md · agent_reports/S12_refute.md §2 Reproduced (SOAK A5: timers created: 4 … STILL-LIVE periodic timers: 2). main() is the only caller and an activity recreation gives a fresh isolate; the only in-isolate second init() is a debug hot restart.
S12R-F2 Journal.snapshot is a static closure over _HomeScreenState that is never cleared, so a torn-down screen and its five AudioPlayers are retained for the life of the isolate LOW — (new) New (refuter contribution) lib/ui/home.dart:93-103 (set), lib/journal.dart:40 (the static field), lib/ui/home.dart:236-243 (dispose, which does not clear it) agent_reports/S12_refute.md §5 The retention mechanism for everything in the S3-F15/S12-F5 row. Same latency caveat as the other lifecycle findings.
S14-F10 SystemUiMode.immersiveSticky is set once and never re-asserted, and no kiosk or screen-pinning mechanism exists, so a cook can leave the timer board mid-service LOW LOW CONFIRMED; ownership contested (S9/S10 for the manifest half) lib/main.dart:33, android/app/src/main/AndroidManifest.xml:22-30 findings/S14_entry_unowned.md · agent_reports/S14_refute.md §1 Greps re-run and recorded. Not among the five findings S14R-M3 reassigns, but its refuter notes the manifest half belongs to S9/S10.

LOW count: 44 rows.


5. INFO — recorded non-defects and reported decisions

These are not defects. They are in the register so the downstream team does not re-derive them.

ID Title Final severity Original severity Verdict File:line (at 03a176e) Evidence path Refuter note
S2-C1 Concurrency: checked, no defect found INFO n/a (checked-clear) CONFIRMED lib/engine/store.dart:133-154, lib/journal.dart:163-180 findings/S2_persistence.md · agent_reports/S2_refute.md Probes W2/W3 reproduce; the single-isolate reasoning and the disjoint-key argument hold for the three data keys. Scope limit: it does not cover the clones→run coupling in S2R-M1, which is a load-ordering issue, not a concurrency one.
S3-F18 No defect: the past-deadline guard, the debounce, the exact-to-inexact degradation, the reboot receiver and the no-double-ring behaviour are all correct INFO LOW (informational) CONFIRMED lib/alarm_backstop.dart:181, :143-148, :208-220 findings/S3_audio_alarms.md · agent_reports/S3_refute.md The refuter mutated five of these mechanisms (_nid, Importance.max, the onForeground cancel loop, the iOS settings, the chain sum) and all five were detected. It also probed the onForeground/sync interleave for a cancel-after-rearm race and found it unreachable. Note the tension with S7-F4, which proves the past-deadline guard is not what makes its own test pass — the mechanism is correct, its test is not.
S4-F18 What was checked and found sound INFO LOW (recorded so the coordinator can see the negative space) UNREFUTED — verdict provisional findings/S4_ui.md
S6-F23 REPORTED, not proposed: the 16 WAVs ship uncompressed INFO LOW CONFIRMED as a non-finding assets/audio/*.wav (15 files), android/app/src/main/res/raw/cadence_alarm.wav findings/S6_dry_deadcode.md · agent_reports/S6_refute.md Correctly framed under R6 as a product decision, not a defect. The 1,664,814-byte saving requires re-validating every tone by ear; S6R-F2 offers 851,968 bytes with no product decision at all.

6. Prior-audit findings still open

From research/01_prior_work.md §2.5: 7 items STILL OPEN in whole or in part from the 2026-07-19 Codex audit of sergemio/cadence-kitchen-timer (source A1) and its recommendation list. They are in the register because they are unclosed obligations, not new discoveries.

ID Prior finding Prior grade Status at 03a176e Where it lands in this audit Evidence path
A1-1 (iOS half) "Production alarm delivery is not guaranteed when hidden or locked — critical" critical FIXED on Android / STILL OPEN on iOS Merged into the BLOCKER row S3-F1 + S9-F07. alarm_backstop.dart:73-76 passes AndroidInitializationSettings only, so init() leaves _ready false on iOS and every scheduling path returns immediately (sync :123, showNow :253-256) research/01_prior_work.md §2.1
A1-3 (CI half) "Verification is not reproducible from the repository — high" high PARTLY FIXED — the CI half is STILL OPEN No row in §1–§5 covers this — it is an open obligation with no owning stream. 13 test files / 123 tests and a dependency manifest now exist, but find . -path '*.github*' returns nothing: no .github/workflows/, no analyze job, nothing that runs the suite other than a human typing flutter test research/01_prior_work.md §2.1 · proof/00_baseline/test.txt
A1-4 / A1-R5 (export half) "Persisted data has no schema validation or recovery — high … there is no export/import or backup" high PARTLY FIXED — export/import/backup STILL OPEN Merged into S10-F1 + S13-F4. Validation and recovery are FIXED (store.dart:91-113, :119-128, :34-59, :133-147); grep -riE 'export\|backup\|restore' lib/ returns only journal.dart:207, which exports the flight recorder, not the configuration research/01_prior_work.md §2.1, §2.2
A1-5 / A1-R6 (web/ half) "Localization metadata and behavior disagree — medium" medium FIXED for the shipped app / STILL OPEN for the web/ shell Merged into S6-F21 + S9-F12 + S14-F7. web/manifest.json:2-7 reads "name": "cadence", "description": "A new Flutter project.", theme_color/background_color #0175C2 (Flutter blue, not the app's beige #E7DED0) research/01_prior_work.md §2.1, §2.2
A1-6 "Core interaction surfaces are touch-only and lack dialog semantics — medium" medium STILL OPEN — the largest of the seven Same defect as S4-F08. grep -rn 'Semantics\|semanticLabel\|excludeSemantics\|tooltip' lib/zero matches across 4,891 lines. Tiles are raw GestureDetectors (tile.dart:372-374, :427-429, :604, :613, :620, :679); modals are showDialogDialog( (modals.dart:13, :16) with no semanticLabel; every control label is a glyph string (i18n.dart:41-43). This is the single largest v2 decision (A4 §14-15) the Flutter line did not carry over research/01_prior_work.md §2.1, §2.4
A1-G2 Device gap: "a device run that starts a short timer, backgrounds or locks the device, and records whether sound begins at the registered deadline" — (open gap) STILL OPEN, and explicitly so No row in §1–§5 covers this — it needs hardware, not code. Commit f46d142 states verbatim: «Reste a valider sur tablette : le secours OS n'a jamais reellement sonne dans ce log (toujours annule avant l'echeance), aucun force-stop / ecran eteint long / eco d'energie / reboot -> les modes de panne restent a prouver.» Nothing later claims to have closed it. This is the verdict-flipping test for the whole product and it has never been run in this workspace research/01_prior_work.md §2.3
A1-G3 Device gap: "a device run that forces wake-lock release or power-saving mode and verifies operator-visible recovery" — (open gap) STILL OPEN Touches S14-F4 (the wakelock banner exists but can never clear, and a silently ineffective wakelock produces no banner at all), which its refuter proved by driving the real main(). The device half remains unrun research/01_prior_work.md §2.3

7. Counts

Bucket Rows
BLOCKER 8
HIGH 45
MEDIUM 88
LOW 44
INFO 4
Register total (§1–§5) 189
Prior-audit open items (§6) 7
Grand total 196
Cut Count
Rows contributed by refuters rather than streams 42
Rows whose severity the refuter changed 22
Rows carrying a REFUTED verdict, in whole or in part 21
— of which fully REFUTED (do not implement) 2 (S6-F18 pad2 half; S12-F3 harm claim)
Merge groups (findings found independently by 2+ sources) 25
Source findings folded into those 25 rows 57
Rows marked UNREFUTED — verdict provisional 48
Unrefuted streams 3 (S4, S8, S13)

8. What the downstream team must not do

Extracted from the refutations, because these are the fixes that would make the product worse or waste the work.

  1. Do not implement S1-F1's proposed fix as written. It was tested and is defective (probe R2b).
  2. Do not implement S9-F03's fix. Adding UIBackgroundModes does not fix the blocking condition; the fix belongs to S9-F07.
  3. Do not implement S3-F3's fix (1) (reply result.error(…)). Proven inert — the SecurityException cannot fire at that call site.
  4. Do not adopt fix_announce_timer_leak.patch as written (S12R-F3). It leaves the OS alarm on a stale deadline — a BLOCKER-class trade for a duplicate notification that was never wrong.
  5. Do not implement S6-F18's pad2 centralisation. Ten library idioms become two copies plus a layering rule.
  6. Do not implement S6-F10(a). _fieldLabel and _settingLabel share no value; merging them needs four optional parameters.
  7. Do not implement S12-F8's fix (b). A black overlay dismissed by touch is a new end-user feature and violates R6.
  8. Do not implement S10-F6's fix. It inverts a written decision (// count-up baseline = the ACTUAL ring instant, not the deadline) without refuting it. Implement the residue instead: lateness is computed and never shown.
  9. Do not implement S9-F16's fix. The causal claim behind it is refuted.
  10. Do not remove the 300 ms backstop debounce (alarm_backstop.dart:36-39). S10-F8's claim that it is now obsolete is refuted; 30 taps in a few seconds is exactly the burst it collapses.
  11. Do not delete keep.xml (S6-F16 + S9-F11). It is belt-and-braces today and load-bearing the moment shrinking is enabled.
  12. Do not populate NSPrivacyAccessedAPITypes if a privacy manifest is added. Apple binds you to what you declare; an empty declaration is safe, an inaccurate one is not.
  13. Do not commit findings/S11_LICENSES_draft.md. NOT APPROVED — 7 corrections required (agent_reports/S11_refute.md §8).
  14. Do not implement the logo_test.dart specification as written (S7R-F7). Its assertion cannot deliver the guard it claims.
  15. Do not treat test/robustness_test.dart:149 as a passing test to preserve when fixing S1-F2 + S5-F1. It enshrines the swallow-and-drop as desired behaviour and must change with the fix.
  16. Do not add EngineHost.onEngineFault as an abstract member. It breaks every implementer in lib/ and test/; it needs a concrete empty default body.
Structured implementation records

S1-F1 · A backward step of the wall clock silences every running timer for the length of the step

What is wrong

A backward step of the wall clock silences every running timer for the length of the step

File and line

lib/engine/engine.dart:312; deadlines set at :177, :182; consumer lib/ui/home.dart:248 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S1_engine.md · proof/01_findings/S1/ · proof/01_findings/S1_refute/. Reproduced on an independently built clean copy; heartbeat proven monotonic (so the app is awake and silent); backstop proven to run on the same RTC_WAKEUP wall clock, so it does not rescue this. Three corrections: the Evidence block is a stitched composite, not verbatim — replace it with s1_facts_test.dart:92-121 as written; the silence is bounded by the size of the step, not permanent; the proposed fix is defective as written (probe R2b) and must be redesigned before implementation.

findings/S1_engine.md § S1-F1 — A backward step of the wall clock silences every running timer for the length of the step: proof/01_findings/S1/facts_run.txt, probes A1 and A3 (PASS = the defective behaviour is real), and proof/01_findings/S1/defects_run.txt, tests S1-F1 and S1-F1b (FAIL). The probe drives the real 150 ms heartbeat for 60 simulated minutes of wall time: dart // s1_facts_test.dart, probe A1 final t = single('a', 300); // a five-minute dish e2.startTimer(t2); h2.t -= 3600000; // operator / NTP steps the clock back one hour for (var ms = 0; ms < 60 * 60 * 1000; ms += Engine.tickMs) { h2.t += Engine.tickMs; e2.tick(); } expect(h2.fired, isEmpty, reason: '60 real minutes on a 5-minute timer: NOT ONE alarm'); expect(e2.run['a']!.status, RunStatus.running); 00:00 +1: A. wall-clock manipulation A1 clock set BACK 1h: a 5-min timer stays silent for 65 minutes 00:00 +3: A. wall-clock manipulation A3 clock BACK while a chain runs: the chain stalls mid-step The symmetric case is equally proven: probe A2 shows a forward step of one hour makes three unrelated dishes ring simultaneously and writes a bogus driftMs of 3301200 into the journal. The failing counterpart, from defects_run.txt: S1-F1 a 5-min timer must ring within 5 min of REAL time even if the wall clock is stepped back one hour [E] S1-F1b a 5-min timer must NOT ring 55 minutes early because the clock was stepped forward one hour [E] Daylight saving and timezone changes are not affected, and that is proven rather than assumed: EngineHost.now() is implemented as DateTime.now().millisecondsSinceEpoch (lib/ui/home.dart:248), which is a UTC instant. Probe A4 pins DateTime.parse('2026-10-25T01:00:00Z').millisecondsSinceEpoch == 1792890000000 and shows .toLocal() yields the identical epoch value. The exposure is clock setting, not clock labelling.

What to fix

CORRECTED FIX: expose Android SystemClock.elapsedRealtime() (or POSIX CLOCK_BOOTTIME) through a platform channel and store a suspend-inclusive monotonic deadline beside the wall-clock deadline. Drive countdown and firing from the monotonic deadline; retain epoch time only for OS notification scheduling and persistence reconciliation. Never use Dart Stopwatch. RED/GREEN TEST: inject independent wall and boot clocks, start a running timer, move wall time backward by one hour while boot time advances normally, and assert the alarm fires after the original duration; add a 24-hour simulated suspend where both elapsed-real-time and the timer duration advance together and assert no 24-hour deadline extension.

How to prove it

findings/S1_engine.md § S1-F1 — A backward step of the wall clock silences every running timer for the length of the step: s1_defects_test.dart::S1-F1 and S1-F1b — currently red. With the fix they are rewritten against a fake host whose now() is stepped by −3 600 000 ms while monotonicMs() continues to advance by tickMs, and they must go green: the five-minute dish rings after five minutes of monotonic time, and the forward step does not make it ring early. ---

S2-F2 · The zone→sound migration is NOT idempotent: a second run rewrites every dish's ringtone to `Bell`

What is wrong

The zone→sound migration is NOT idempotent: a second run rewrites every dish's ringtone to Bell

File and line

lib/engine/store.dart:255-285; false claim at :249-252; mechanism lib/engine/models.dart:56-63 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · proof/01_findings/S2/ · proof/01_findings/S2_refute/. Reproduced twice from scratch, once with no write failure at all — the trigger needs no wrong-typed value. One wording correction: _guard (store.dart:174-180) does observe and report the write result; what it does not do is raise it as critical, retry, or let a caller await it. Delete "never verifies" before this reaches the report.

findings/S2_persistence.md § S2-F2 — The zone→sound migration is NOT idempotent: a second run rewrites every dish's ringtone to Bell: the claim, verbatim: dart // store.dart:249-252 /// Idempotent by construction: it re-reads the same zones and assigns the /// same tones, so dying before the flag is written costs nothing. The order /// matters and is deliberate — save the timers FIRST, then flag, then drop /// the legacy key, so no interruption can lose a tone. The lookup it depends on: dart // store.dart:273-279 for (final t in e.timers) { final inherited = tones[t.legacyZoneId] ?? _legacyFallbackSound; // 'Bell' Recorded run proof/01_findings/S2/02_migration.txt: DEFECT 1b — the migration is NOT idempotent across a reload boot1 sounds={Fries: Beep, Crispy: Beep, Dough: Chime} boot2 moved=3 sounds={Fries: Bell, Crispy: Bell, Dough: Bell} DEFECT 1 — ... silently reverts an operator's tone choice SECOND RUN moved=3, sounds now {Fries: Bell, Crispy: Bell, Dough: Bell} The existing test named "ne tourne QU'UNE fois" (store_test.dart:145-158) does not test this: it never reloads from storage and relies on the flag still being set, so it proves the flag gate works and says nothing about idempotency.

What to fix

findings/S2_persistence.md § S2-F2 — The zone→sound migration is NOT idempotent: a second run rewrites every dish's ringtone to Bell: make the guard structural rather than flag-dependent. Only apply an inherited tone when the timer actually carries a legacyZoneId, i.e. replace line 274 with dart final zid = t.legacyZoneId; if (zid == null) continue; // post-zones timer: never touch final inherited = tones[zid] ?? _legacyFallbackSound; This makes the migration idempotent by construction for real, independently of the flag, and also fixes S2-F4. Additionally, verify the flag write: _guard should report the _kZoneSound failure as isCritical: true so a lost one-shot flag is visible.

How to prove it

findings/S2_persistence.md § S2-F2 — The zone→sound migration is NOT idempotent: a second run rewrites every dish's ringtone to Bell: s2_migration_test.dart, tests "DEFECT 1b — the migration is NOT idempotent across a reload" and "DEFECT 4 — a v0.4.11+ timer carried through the migration is forced to Bell". Both assert the defect today; invert to expect(_sounds(boot2), _sounds(boot1)) and expect(_sounds(e)['Baklava'], 'Cascade'). Red now, green after. ---

S3-F1 + S9-F07 (+ prior A1-1, iOS half) · The OS alarm backstop is dead on iOS: the notification plugin is initialised Android-only and no iOS sound resource is bundled, so the app boots into a permanent red banner and iOS has nothing to play

What is wrong

The OS alarm backstop is dead on iOS: the notification plugin is initialised Android-only and no iOS sound resource is bundled, so the app boots into a permanent red banner and iOS has nothing to play

File and line

lib/alarm_backstop.dart:72-76 (InitializationSettings has no DarwinInitializationSettings); _ready gate :79-96; ios/Runner.xcodeproj/project.pbxproj PBXResourcesBuildPhase block 97C146EC1CF9000F007C117D (no sound resource) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · findings/S9_platform_config.md · agent_reports/S3_refute.md · agent_reports/S9_refute.md. Found independently by S3 (audio/backstop angle) and S9 (platform-config angle). S3's refuter verified the defect against the plugin source at flutter_local_notifications_plugin.dart:142-147 and by mutation B4. S9's refuter raised its own F07 to BLOCKER and folded S9-F03's blocking condition into it. Also the still-open half of the 2026-07-19 prior audit's A1-1.

findings/S3_audio_alarms.md § S3-F1 — the OS-level alarm backstop is dead on iOS, and the app boots into a permanent red banner: plugin source, flutter_local_notifications-22.1.0/lib/src/flutter_local_notifications_plugin.dart:142-147: dart } else if (defaultTargetPlatform == TargetPlatform.iOS) { if (settings.iOS == null) { throw ArgumentError( 'iOS settings must be set when targeting iOS platform.', ); } App source, lib/alarm_backstop.dart:72-76: dart await _plugin.initialize( settings: const InitializationSettings( android: AndroidInitializationSettings('@mipmap/ic_launcher'), ), ); Test run, proof/01_findings/S3/02_test_full_suite_with_s3.txt (test S3: on iOS, Backstop.init() fails and the app carries a permanent critical banner with NO OS-level safety net, source proof/01_findings/S3/tests/s3_backstop_test.dart:78-102), console line: [cadence] backstop-init: Invalid argument(s): iOS settings must be set when targeting iOS platform. The test asserts b.ready == false, Diag.critical contains backstop-init, zero zonedSchedule calls and zero show calls. findings/S9_platform_config.md § S9-F07 — No iOS notification sound resource is bundled and the notification plugin is initialised Android-only, so the iOS backstop has nothing to play: The complete Runner resources build phase, verbatim from ios/Runner.xcodeproj/project.pbxproj: 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; Verbatim, lib/alarm_backstop.dart:71-76 — Android-only initialisation: dart await _plugin.initialize( settings: const InitializationSettings( android: AndroidInitializationSettings('@mipmap/ic_launcher'), ), ); $ grep -rn "DarwinInitializationSettings\|DarwinNotificationDetails" lib/ (no output) Static analysis only — no Xcode on this machine, so the bundle contents are read from the project file rather than from a built .app.

What to fix

findings/S3_audio_alarms.md § S3-F1 — the OS-level alarm backstop is dead on iOS, and the app boots into a permanent red banner: add iOS: DarwinInitializationSettings(requestAlertPermission: true, requestSoundPermission: true, requestBadgePermission: false) to the InitializationSettings at alarm_backstop.dart:73-75; add iOS: const DarwinNotificationDetails(presentAlert: true, presentSound: true, sound: 'cadence_alarm.wav', interruptionLevel: InterruptionLevel.timeSensitive) to _details at alarm_backstop.dart:61-62; bundle cadence_alarm.wav in the Runner target (the Android copy already exists at android/app/src/main/res/raw/cadence_alarm.wav); and resolve IOSFlutterLocalNotificationsPlugin for the permission request alongside the Android one at alarm_backstop.dart:77-80. No new user-facing capability — this is the iOS half of an existing one. findings/S9_platform_config.md § S9-F07 — No iOS notification sound resource is bundled and the notification plugin is initialised Android-only, so the iOS backstop has nothing to play: Add a CAF or WAV alarm sound to the Runner target's resources build phase (the same waveform tools/build_ringtones.py already writes for Android, converted with afconvert), extend the initialisation to InitializationSettings(android: …, iOS: DarwinInitializationSettings(requestAlertPermission: true, requestSoundPermission: true, requestCriticalPermission: false)), and pass DarwinNotificationDetails(sound: 'cadence_alarm.caf', interruptionLevel: InterruptionLevel.timeSensitive) in _details. Coordinate with S3, which owns the runtime path.

How to prove it

findings/S3_audio_alarms.md § S3-F1 — the OS-level alarm backstop is dead on iOS, and the app boots into a permanent red banner: proof/01_findings/S3/tests/s3_backstop_test.dart:78-102 asserts the broken state today; against the fix, invert it to expect(b.ready, isTrue) plus expect(only('initialize'), hasLength(1)) under debugDefaultTargetPlatformOverride = TargetPlatform.iOS. That inverted test is red before the fix (the plugin throws) and green after. findings/S9_platform_config.md § S9-F07 — No iOS notification sound resource is bundled and the notification plugin is initialised Android-only, so the iOS backstop has nothing to play: A Dart test asserting Backstop's InitializationSettings exposes a non-null iOS member and that _details.iOS?.sound is non-null. Red now (both are null), green after. Plus, once Xcode exists, unzip -l Runner.app | grep -c '\.caf' returns ≥ 1. ---

S3-F2 + S9-F03 · An iOS build cannot ring at all once it leaves the screen: `UIBackgroundModes` is absent from the whole `Info.plist`

What is wrong

An iOS build cannot ring at all once it leaves the screen: UIBackgroundModes is absent from the whole Info.plist

File and line

ios/Runner/Info.plist:4-69 — key absent from the top-level <dict> (file is 70 lines, not the 63 S3's manifest records) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · findings/S9_platform_config.md · agent_reports/S3_refute.md · agent_reports/S9_refute.md. Both refuters enumerated the plist keys themselves and confirmed the absence (grep -c UIBackgroundModes → 0; 18 top-level keys). S9's refuter split the finding: the fact holds, but adding UIBackgroundModes does not fix it — the blocking condition belongs to S9-F07 (the merged row above) and that is where the Phase-4 fix goes. Do not implement S9-F03's fix as written.

findings/S3_audio_alarms.md § S3-F2 — an iOS build cannot ring at all once it leaves the screen: ios/Runner/Info.plist contains, in full, only these top-level keys: CADisableMinimumFrameDurationOnPhone, CFBundleDevelopmentRegion, CFBundleDisplayName, CFBundleExecutable, CFBundleIdentifier, CFBundleInfoDictionaryVersion, CFBundleName, CFBundlePackageType, CFBundleShortVersionString, CFBundleSignature, CFBundleVersion, LSRequiresIPhoneOS, UIApplicationSceneManifest, UIApplicationSupportsIndirectInputEvents, UILaunchStoryboardName, UIMainStoryboardFile, UISupportedInterfaceOrientations, UISupportedInterfaceOrientations~ipad. No UIBackgroundModes, and no audio background mode. The wakelock (lib/main.dart:28, WakelockPlus.enable()) prevents the screen sleeping while the app is in front; it does not keep a backgrounded app scheduled. findings/S9_platform_config.md § S9-F03 — UIBackgroundModes is absent from Info.plist, so the iOS build cannot sound an alarm once the app leaves the screen: The complete set of top-level keys in ios/Runner/Info.plist (70 lines, read in full): CADisableMinimumFrameDurationOnPhone, CFBundleDevelopmentRegion, CFBundleDisplayName, CFBundleExecutable, CFBundleIdentifier, CFBundleInfoDictionaryVersion, CFBundleName, CFBundlePackageType, CFBundleShortVersionString, CFBundleSignature, CFBundleVersion, LSRequiresIPhoneOS, UIApplicationSceneManifest, UIApplicationSupportsIndirectInputEvents, UILaunchStoryboardName, UIMainStoryboardFile, UISupportedInterfaceOrientations, UISupportedInterfaceOrientations~ipad. There is no UIBackgroundModes. $ grep -c UIBackgroundModes ios/Runner/Info.plist 0 The key and its permitted values, from Apple (https://developer.apple.com/documentation/bundleresources/information-property-list/uibackgroundmodes, retrieved 2026-08-04, captures/apple_uibackgroundmodes.txt), verbatim: > UIBackgroundModes — Services provided by an app that require it to run in the background. > […] Possible Values: audio, bluetooth-central, bluetooth-peripheral, external-accessory, > fetch, location, nearby-interaction, network-authentication, newsstand-content, > processing, push-to-talk, remote-notification, voip The verbatim comment in ios/Runner/AppDelegate.swift:19-21 showing that .playback was chosen for the mute switch, not for backgrounding: swift // • Alarm stream: iOS has no per-stream volume. The equivalent guarantee is // the .playback audio session category, which keeps sound audible even when // the physical silent switch is ON — the closest analogue of USAGE_ALARM. Static analysis only — no Xcode on this machine, so this is read from the declaration files and not observed on a device.

What to fix

CORRECTED FIX: do not add UIBackgroundModes: audio. Implement the Darwin local-notification repair in store_readiness/04_ios_blockers.md §5 with DarwinInitializationSettings, DarwinNotificationDetails, a bundle-root sound, and the time-sensitive entitlement. RED/GREEN TEST: the iOS platform-override arm from proof/02_store/s02_probe_platform_test.dart must change from Backstop.ready=false and zero channel calls to ready=true with a non-null Darwin zonedSchedule payload.

How to prove it

findings/S3_audio_alarms.md § S3-F2 — an iOS build cannot ring at all once it leaves the screen: device protocol D1 below. Static proof is not available for this one and is not claimed. findings/S9_platform_config.md § S9-F03 — UIBackgroundModes is absent from Info.plist, so the iOS build cannot sound an alarm once the app leaves the screen: A plist assertion runnable without Xcode: /usr/libexec/PlistBuddy -c "Print :UIBackgroundModes:0" ios/Runner/Info.plist must print audio. Red now (Print: Entry, ":UIBackgroundModes:0", Does Not Exist), green after. On-device confirmation must be added once Xcode is installed: background the app with a timer running and confirm the ringtone plays. ---

S3-F3 · `setStreamVolume` failure is swallowed in Kotlin and never verified in Dart, so the 15 % audible floor is a claim the app cannot back

What is wrong

setStreamVolume failure is swallowed in Kotlin and never verified in Dart, so the 15 % audible floor is a claim the app cannot back

File and line

android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:57-59 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md §Part 1 · proof/01_findings/S3_refute/. The severity survives; the justification does not. The refuter went to AOSP AudioService.java:4495-4504, :3745-3765, :4613-4626, :6724-6752 and proved the cited SecurityException cannot fire at this call site — the app passes flags = 0, and the throw is guarded by wouldToggleZenMode(getNewRingerMode(...)) which needs FLAG_ALLOW_RINGER_MODES. Consequence: fix (1) (reply result.error(...)) is inert; promote fix (2) to the whole remedy. See S3R-F2.

findings/S3_audio_alarms.md § S3-F3 — setStreamVolume failure is swallowed in Kotlin and never verified in Dart, so the 15% audible floor is a claim the app cannot back: the catch above, verbatim from MainActivity.kt:57-58. The documented throw, from proof/01_findings/S3/captures/android_audiomanager.txt:4646 and :4663 (retrieved 2026-08-04, https://developer.android.com/reference/android/media/AudioManager): From N onward, volume adjustments that would toggle Do Not Disturb are not allowed unless the app has been granted Notification Policy Access. See NotificationManager.isNotificationPolicyAccessGranted(). ... Throws SecurityException if the volume change triggers a Do Not Disturb change and the caller is not granted notification policy access. Dart-side blindness, proven by test S3: a setAlarmVolume that FAILS is recorded but NOT critical, so the operator is never warned the alarm stream was not set (proof/01_findings/S3/tests/s3_volume_channel_test.dart): when the channel itself throws, the app records volume-set in Diag.log but Diag.critical.value stays empty — no operator banner. When the Kotlin swallows instead, not even that happens, because result.success(null) means the Dart future completes normally. The correct ordering is in place and is now proven: test S3: on the rising edge of a ring the operator level is written to STREAM_ALARM BEFORE the ringtone is handed to the player asserts the observed sequence ['volume:0.4', 'volume:0.4', 'play'] (boot assertion, rising edge, WAV), so the design is right and only the failure reporting is missing. agent_reports/S3_refute.md § S3R-F2 — the SecurityException S3 uses to justify BLOCKER S3-F3 cannot fire at that call site, and 1 of its 3 proposed fixes is inert: full quoted capture with line numbers and a sha256 of the decoded source in proof/01_findings/S3_refute/captures/aosp_audioservice_setStreamVolume.txt. Key blocks quoted in Part 1 above.

What to fix

CORRECTED FIX: do not rely on returning result.error from the cited SecurityException catch; that exception cannot occur with flags == 0. After setting the alarm-stream volume, read it back and compare it with the requested floor; return a structured false/result value on mismatch, propagate that result through Dart, and surface it through a renderable critical scope. RED/GREEN TEST: substitute an audio manager whose setter returns without changing the stream value; current code reports success, fixed code reports failure and raises the operator banner.

How to prove it

findings/S3_audio_alarms.md § S3-F3 — setStreamVolume failure is swallowed in Kotlin and never verified in Dart, so the 15% audible floor is a claim the app cannot back: a test in test/volume_test.dart that mocks cadence/volume to answer PlatformException(code: 'volume_write_failed') on setAlarmVolume and asserts Diag.critical.value contains volume-set. Red today — the test named above records that the current code marks it non-critical — green after. Kotlin side: device protocol D2. agent_reports/S3_refute.md § S3R-F2 — the SecurityException S3 uses to justify BLOCKER S3-F3 cannot fire at that call site, and 1 of its 3 proposed fixes is inert: a test in test/volume_test.dart mocking cadence/volume so setAlarmVolume answers a different level from the one requested (say 0.0 for a request of 0.4) and asserting the operator is told. That test cannot be written today because the verb returns null; it is red the moment the read-back lands and the comparison is missing, green after.

S9-F01 · The release build type signs with the debug keystore; both APK and AAB carry `CN=Android Debug`, which Google Play does not accept

What is wrong

The release build type signs with the debug keystore; both APK and AAB carry CN=Android Debug, which Google Play does not accept

File and line

android/app/build.gradle.kts:30-36 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S9_platform_config.md · proof/01_findings/S9_refute/. Reproduced on the refuter's own build of both artefacts. Policy source: https://developer.android.com/studio/publish/app-signing.

findings/S9_platform_config.md § S9-F01 — The release build type signs with the debug keystore; both the APK and the AAB carry CN=Android Debug, which Google Play does not accept: Verbatim, android/app/build.gradle.kts:30-36: kotlin buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") } } Measured on both artifacts (proof/01_findings/S9/apk_aab_signing.txt): Signer #1 certificate DN: C=US, O=Android, CN=Android Debug Signer #1 certificate SHA-256 digest: 49d5b0ff27a90c3e017dcd7c04cd979111cce9a8de4a666b2c492e13800e1aae === AAB certificate === Owner: C=US, O=Android, CN=Android Debug Issuer: C=US, O=Android, CN=Android Debug Serial number: 1 Valid from: Tue Aug 04 11:37:42 CEST 2026 until: Thu Jul 27 11:37:42 CEST 2056 SHA256: 49:D5:B0:FF:27:A9:0C:3E:01:7D:CD:7C:04:CD:97:91:11:CC:E9:A8:DE:4A:66:6B:2C:49:2E:13:80:0E:1A:AE What Google Play does with such an upload, from the official documentation (https://developer.android.com/studio/publish/app-signing, retrieved 2026-08-04, captures/android_app_signing.txt:156), verbatim: > Because the debug certificate is created by the build tools and is insecure by design, most app > stores (including the Google Play Store) do not accept apps signed with a debug certificate for > publishing. And on the impermanence of that key (captures/android_app_signing.txt:164-172), verbatim: > The self-signed certificate used to sign your app for debugging has an expiration date of 30 > years from its creation date. […] To fix this problem, simply delete the debug.keystore file […] > The next time you build and run a debug version of your app, Android Studio regenerates a new > keystore and debug key.

What to fix

findings/S9_platform_config.md § S9-F01 — The release build type signs with the debug keystore; both the APK and the AAB carry CN=Android Debug, which Google Play does not accept: Generate an upload keystore outside the repo (keytool -genkey -v -keystore <path>/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload), add an untracked android/key.properties (already covered by android/.gitignore:12), and replace lines 30-36 with a real config: kotlin signingConfigs { create("release") { val props = java.util.Properties() val f = rootProject.file("key.properties") if (f.exists()) f.inputStream().use { props.load(it) } keyAlias = props.getProperty("keyAlias") keyPassword = props.getProperty("keyPassword") storeFile = props.getProperty("storeFile")?.let { file(it) } storePassword = props.getProperty("storePassword") } } buildTypes { release { signingConfig = signingConfigs.getByName("release") } } Then opt in to Play App Signing at first upload, so a lost upload key can be reset without losing the app (captures/android_app_signing.txt:116).

How to prove it

findings/S9_platform_config.md § S9-F01 — The release build type signs with the debug keystore; both the APK and the AAB carry CN=Android Debug, which Google Play does not accept: flutter build appbundle --release followed by keytool -printcert -jarfile build/app/outputs/bundle/release/app-release.aab | grep Owner. Red now: prints Owner: C=US, O=Android, CN=Android Debug. Green after: prints the upload certificate's distinguished name, and CN=Android Debug appears nowhere in the output. ---

S9-F02 + R-F1 (S11's refuter) · The iOS App Store icon set is the byte-identical Flutter placeholder logo; `flutter_launcher_icons` was configured to skip iOS

What is wrong

The iOS App Store icon set is the byte-identical Flutter placeholder logo; flutter_launcher_icons was configured to skip iOS

File and line

pubspec.yaml:35 (ios: false); ios/Runner/Assets.xcassets/AppIcon.appiconset/*.pngall 15, not only Icon-App-1024x1024@1x.png at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S9_platform_config.md · agent_reports/S9_refute.md §BLOCKER 2 · agent_reports/S11_refute.md §7 R-F1. Found twice, independently. S9 found it from the platform-config side and graded BLOCKER; S11's refuter found it from the asset-provenance side after discovering S11 had omitted ios/ from its inventory entirely (R-F2), and graded it BLOCKER without knowledge of S9's row. S9's refuter proved all 15 icons are template, not just the 1024. Root cause is a single line: pubspec.yaml:35.

findings/S9_platform_config.md § S9-F02 — The iOS App Store icon is the byte-identical Flutter placeholder logo; flutter_launcher_icons was configured to skip iOS: Verbatim, pubspec.yaml:33-39: yaml flutter_launcher_icons: android: true ios: false image_path: "assets/icon/ic_legacy.png" adaptive_icon_background: "#F4EFE4" adaptive_icon_foreground: "assets/icon/ic_foreground.png" adaptive_icon_monochrome: "assets/icon/ic_monochrome.png" Byte-identity against the Flutter SDK's own template copy (Flutter 3.44.8): MD5 (ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png) = c785f8932297af4acd5f5ccb7630f01c MD5 (Flutter host_app_ephemeral/Runner.tmpl/Assets.xcassets/AppIcon.appiconset/ Icon-App-1024x1024@1x.png) = c785f8932297af4acd5f5ccb7630f01c Visual confirmation: the repo's 1024×1024 icon renders as the blue Flutter chevron; the Android launcher icon at android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png renders as the Cadence 7-segment mark on the #F4EFE4 ground. The asset catalogue has never been touched since the initial port: $ git log --oneline -- ios/Runner/Assets.xcassets/ 22902e0 Cadence v0.2.0 — app Flutter (moteur + UI + audio natif) avec lot robustesse App Store Review Guidelines (https://developer.apple.com/app-store/review/guidelines/, retrieved 2026-08-04, captures/appstore_review_guidelines.txt:155), Guideline 2.1 App Completeness, verbatim: > Submissions to App Review […] should be final versions with all necessary metadata and fully > functional URLs included; placeholder text, empty websites, and other temporary content should be > scrubbed before submission. And Guideline 2.3.9 (captures/appstore_review_guidelines.txt:175), verbatim: > You are responsible for securing the rights to use all materials in your app icons, screenshots, > and previews […] agent_reports/S11_refute.md § R-F1 — The iOS App Store icon is Google's Flutter logo: R29 (config and hashes), R30 (byte comparison against flutter_template_images 5.0.0), R31 (Android contrast). === pubspec flutter_launcher_icons ios setting === flutter_launcher_icons: android: true ios: false image_path: "assets/icon/ic_legacy.png" BIT-IDENTICAL-TO-FLUTTER-TEMPLATE ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png BIT-IDENTICAL-TO-FLUTTER-TEMPLATE ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png … (all 15 AppIcon sizes) … BIT-IDENTICAL-TO-FLUTTER-TEMPLATE ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png BIT-IDENTICAL-TO-FLUTTER-TEMPLATE ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png BIT-IDENTICAL-TO-FLUTTER-TEMPLATE ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png I rendered Icon-App-1024x1024@1x.png (1024×1024 RGB, sha256 7770183009e91411…) and confirmed by eye that it is the Flutter logo. For contrast, android/.../mipmap-xxxhdpi/ic_launcher.png renders as the Cadence seven-segment mark in the #E8600F / #828A80 / #F4EFE4 palette (R31), proving the Android path was regenerated and the iOS path was not.

What to fix

findings/S9_platform_config.md § S9-F02 — The iOS App Store icon is the byte-identical Flutter placeholder logo; flutter_launcher_icons was configured to skip iOS: Set ios: true in pubspec.yaml:35, supply the 1024×1024 source (the Android path already points at Serge's artwork under assets/icon/), and run dart run flutter_launcher_icons. The iOS marketing icon must be opaque with no alpha channel and no rounded corners; Apple applies the mask. agent_reports/S11_refute.md § R-F1 — The iOS App Store icon is Google's Flutter logo: set ios: true at pubspec.yaml:35 and run dart run flutter_launcher_icons, regenerating the AppIcon set from assets/icon/ic_legacy.png as Android already does. Compliance plumbing, no new feature, inside R6.

How to prove it

findings/S9_platform_config.md § S9-F02 — The iOS App Store icon is the byte-identical Flutter placeholder logo; flutter_launcher_icons was configured to skip iOS: A shell check run in CI: md5 -q ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png must not equal c785f8932297af4acd5f5ccb7630f01c, and sips -g hasAlpha on the same file must report no. Red now (hash matches the template), green after. --- agent_reports/S11_refute.md § R-F1 — The iOS App Store icon is Google's Flutter logo: red now, green after — shasum -a 256 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png must not return 7770183009e914112de7d8ef1d235a6a30c5834424858e0d2f8253f6b8d31926, and no iOS AppIcon hash can appear in flutter_template_images 5.0.0.

S9-F04 · The iOS project has never been configured for a real signing identity, and the required Xcode/SDK toolchain is not installed

What is wrong

The iOS project has never been configured for a real signing identity, and the required Xcode/SDK toolchain is not installed

File and line

ios/Runner.xcodeproj/project.pbxproj:349, :469, :526 (no DEVELOPMENT_TEAM) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S9_platform_config.md · agent_reports/S9_refute.md §BLOCKER 4. Confirmed, and the 2026-04-28 Xcode-26 requirement date verified verbatim against https://developer.apple.com/news/upcoming-requirements/.

findings/S9_platform_config.md § S9-F04 — The iOS project has never been configured for a real signing identity, and the required Xcode/SDK toolchain is not installed: Every signing-related setting in the 644-line project file, complete: $ grep -n "DEVELOPMENT_TEAM\|CODE_SIGN\|ProvisioningStyle\|entitlements" ios/Runner.xcodeproj/project.pbxproj 349: "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 397: CODE_SIGN_STYLE = Automatic; <- RunnerTests target only 414: CODE_SIGN_STYLE = Automatic; <- RunnerTests target only 429: CODE_SIGN_STYLE = Automatic; <- RunnerTests target only 469: "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 526: "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; DEVELOPMENT_TEAM appears zero times. No entitlements file exists in the tree: $ find . -name "*.entitlements" -not -path "./.git/*" (no output) Apple's current upload requirement (https://developer.apple.com/news/upcoming-requirements/, retrieved 2026-08-04, captures/apple_upcoming_requirements.txt:22-26), verbatim: > SDK minimum requirements — Since April 28, 2026 — Apps uploaded to App Store Connect must be > built with Xcode 26 or later using an SDK for iOS 26, iPadOS 26, tvOS 26, visionOS 26, or > watchOS 26. Toolchain state, from the project baseline (proof/00_baseline/doctor.txt), verbatim: ✗ Xcode installation is incomplete; a full installation is necessary for iOS and macOS development. ! CocoaPods not installed.

What to fix

findings/S9_platform_config.md § S9-F04 — The iOS project has never been configured for a real signing identity, and the required Xcode/SDK toolchain is not installed: Enrol in the Apple Developer Program, then set DEVELOPMENT_TEAM = <TEAMID> and CODE_SIGN_STYLE = Automatic on the Runner target's Debug, Release and Profile configurations, and register the bundle identifier dev.sergemio.cadence in the Developer portal. Install Xcode 26 from the Mac App Store, then sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer, sudo xcodebuild -runFirstLaunch, and brew install cocoapods. This unblocks S9-F02, F03, F07 and F09, none of which can be verified on-device until it is done.

How to prove it

findings/S9_platform_config.md § S9-F04 — The iOS project has never been configured for a real signing identity, and the required Xcode/SDK toolchain is not installed: flutter build ipa --release exits 0 and produces a signed .ipa, and grep -c DEVELOPMENT_TEAM ios/Runner.xcodeproj/project.pbxproj returns a non-zero count. Red now (build fails: no Xcode, no team), green after. ---

M1 (S10's refuter) · One accidental tap on the biggest target on the board converts a cooking dish into one that will never ring, and cancels its OS safety net

What is wrong

One accidental tap on the biggest target on the board converts a cooking dish into one that will never ring, and cancels its OS safety net

File and line

lib/ui/home.dart:387-399, lib/engine/engine.dart:243-251, lib/alarm_backstop.dart:104-106 and :127-128 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S10_refute.md §6. A single tap on a running tile pauses it. The tile is the largest touch target on the board and the same target the product asks the cook to slap to silence an alarm. pauseTimer sets endsAt = null, so the engine will never fire it, and _desired then drops it so the scheduled OS alarm is cancelled too. Both layers of protection removed by one accidental tap.

findings/S2_persistence.md § S2-F14 — Deleting the migration's persistence step passes all 123 existing tests (mutation S2-M1): mutation patch saved at proof/01_findings/S2/mutation_M1.patch: diff --- store.dart.orig +++ lib/engine/store.dart @@ -280 +280 @@ - if (moved > 0) saveDefs(e); + // MUTATION S2-M1: migration no longer persists the inherited tones Recorded run proof/01_findings/S2/06_mutation_M1_existing_suite.txt — the full baseline suite under the mutation: 00:02 +123: All tests passed! EXIT_CODE=0 Recorded run proof/01_findings/S2/07_mutation_M1_probe_goes_red.txt — the S2 regression test added for this finding, under the same mutation: 00:00 +2 -1: GAP — nothing in the suite asserts the migration PERSISTS the tones [E] Expected: {'Fries': 'Beep', 'Crispy': 'Beep', 'Dough': 'Chime'} Actual: {'Fries': null, 'Crispy': null, 'Dough': null} EXIT_CODE=1 The same test is green on unmutated code (proof/01_findings/S2/02_migration.txt, 11/11 pass). agent_reports/S10_refute.md § M1 — One accidental tap on the biggest target on the board converts a cooking dish into one that will never ring, and cancels its OS safety net: dart // lib/engine/engine.dart:243-251 void pauseTimer(String id) { final r = run[id]; if (r == null || r.status != RunStatus.running) return; r.status = RunStatus.paused; r.remainingMs = math.max(0, r.endsAt! - host.now()); r.endsAt = null; host.persistRun(); host.onClick(false); } dart // lib/alarm_backstop.dart:103-106 final r = engine.run[t.id]; if (r == null || r.status != RunStatus.running || r.endsAt == null) { continue; } Visual signal exists but is quiet: tile.dart:196 sets pieFill = C.pausedFill (theme.dart:30, a grey 0xFFC9C2B5) and tile.dart:208-211 greys the ink; the breathing animation that is meant to carry the state (tile.dart:257-259) is disabled outright when the platform reports reduce-motion (_reduced, tile.dart:99). agent_reports/S14_refute.md § S14R-M1 — The analyser's strict type-checking modes were never measured, and turning them on surfaces six ERROR-severity type holes, one of them on the exact line S14 wrote finding F4 about: the whole analyzer: block added to a pristine copy, no source change (proof/01_findings/S14_refute/analysis_options_strict_modes.yaml [not published]), run recorded in proof/01_findings/S14_refute/analyze_strict_modes.txt13 issues, 6 of them error: error • The argument type 'dynamic' can't be assigned to the parameter type 'Object'. • lib/main.dart:31:27 • argument_type_not_assignable error • The argument type 'dynamic' can't be assigned to the parameter type 'Map<dynamic, dynamic>'. • lib/engine/models.dart:76:72 • argument_type_not_assignable error • The argument type 'dynamic' can't be assigned to the parameter type 'Object'. • lib/engine/store.dart:142:32 • argument_type_not_assignable error • The argument type 'dynamic' can't be assigned to the parameter type 'Object'. • lib/engine/store.dart:178:34 • argument_type_not_assignable error • The argument type 'dynamic' can't be assigned to the parameter type 'Object'. • lib/audio/voice.dart:197:33 • argument_type_not_assignable error • The argument type 'dynamic' can't be assigned to the parameter type 'Object'. • lib/alarm_backstop.dart:231:36 • argument_type_not_assignable plus six inference_failure_on_untyped_parameter warnings on the matching catchError((e) { lines. The one that carries runtime risk is lib/engine/models.dart:76: dart : (j['steps'] as List) .map((s) => StepDef.fromJson(Map<String, dynamic>.from(s))) s is dynamic. If a persisted timer's steps array holds anything that is not a map — the exact situation lib/engine/store.dart's _preserveCorrupt path exists for — Map.from(s) throws at load time. strict-casts names it statically today; nothing does now. agent_reports/S2_refute.md § S2R-M1 — A corrupt clones value silently destroys the run entries of every batch in flight: M1 clones key corrupt -> runs=1/4 clones=0/3 | run backup=null | banner={load-cadence-clones-v1} and the row pair in §6 (C0 runs=6/6C1 runs=3/6). After the probe calls saveRun, the persisted run map holds one entry.

What to fix

findings/S2_persistence.md § S2-F14 — Deleting the migration's persistence step passes all 123 existing tests (mutation S2-M1): adopt s2_migration_test.dart's test "GAP — nothing in the suite asserts the migration PERSISTS the tones" into test/store_test.dart's migration group. It decodes prefs.getString('cadence-timers-v1') after the call and asserts the tone of each dish. agent_reports/S10_refute.md § M1 — One accidental tap on the biggest target on the board converts a cooking dish into one that will never ring, and cancels its OS safety net: within R6 as defect repair — keep an OS backstop armed for a paused run is not possible (there is no deadline), so the in-scope repair is the operator-visible one: the board has no aggregate signal that a dish is paused. Report the confirmation/undo question as the product decision; the missing paused row in the failure analysis is a reporting defect to correct now. agent_reports/S14_refute.md § S14R-M1 — The analyser's strict type-checking modes were never measured, and turning them on surfaces six ERROR-severity type holes, one of them on the exact line S14 wrote finding F4 about: add to analysis_options.yaml, alongside the lint rules chosen in §2.5: yaml analyzer: language: strict-casts: true strict-inference: true strict-raw-types: true and fix the 13 sites — six by typing the catchError parameter (Object e), one by giving models.dart:76 an explicit as Map check, the rest mechanical. Compliance plumbing, in scope under R6. agent_reports/S2_refute.md § S2R-M1 — A corrupt clones value silently destroys the run entries of every batch in flight: when _readList(_kClones, …) reports a drop or a total failure, do not let reconcile silently harvest the orphaned run entries — preserve cadence-run-v1 to its own .corrupt sibling in the same load, and raise the existing load-cadence-clones-v1 critical with a count of the run entries about to be dropped.

How to prove it

findings/S2_persistence.md § S2-F14 — Deleting the migration's persistence step passes all 123 existing tests (mutation S2-M1): re-apply mutation_M1.patch and confirm flutter test exits non-zero. It exits 0 today. --- agent_reports/S10_refute.md § M1 — One accidental tap on the biggest target on the board converts a cooking dish into one that will never ring, and cancels its OS safety net: extend test/backstop_test.dart with a case that arms a running timer, pauses it, and asserts the scheduled alarm was cancelled — green today, which is the point: the behaviour is real and untested as a risk, not as a feature. agent_reports/S14_refute.md § S14R-M1 — The analyser's strict type-checking modes were never measured, and turning them on surfaces six ERROR-severity type holes, one of them on the exact line S14 wrote finding F4 about: flutter analyze --fatal-infos --fatal-warnings must exit 0 with the strict block present. It exits 1 with 13 issues at 03a176e — recorded above. Then remove the explicit type from one catchError parameter and confirm flutter analyze returns to non-zero, proving the gate is live rather than merely present. agent_reports/S2_refute.md § S2R-M1 — A corrupt clones value silently destroys the run entries of every batch in flight: my test "M1 — a corrupt clones value silently destroys the RUN entries…". Invert expect(store.prefs.getString('cadence-run-v1.corrupt'), isNull) to isNotNull. Red now, green after.

S10-F1 + S13-F4 · A restaurant's entire configuration exists in one copy, on one tablet, with no in-app export and no import

What is wrong

A restaurant's entire configuration exists in one copy, on one tablet, with no in-app export and no import

File and line

lib/engine/store.dart:15-24 (the only persistence keys); lib/journal.dart:207 (the only export in lib/, and it exports the flight recorder, not the configuration); Settings surface lib/ui/modals.dart:616-693 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · findings/S13_data.md · agent_reports/S10_refute.md §1 · agent_reports/S13_refute.md · research/01_prior_work.md A1-4 / A1-R5. Found three times — S10, S13, and the 2026-07-19 prior audit (A1-4 and A1-R5, both recorded STILL OPEN) — and the two refuters disagree. S10's refuter downgraded to MEDIUM because Android Auto Backup supplies backup, restore and second-device transfer by platform default for exactly the cadence-timers-v1 key. S13's refuter confirmed HIGH. Resolved HIGH: S13-R2 measured that the platform default S10's refuter relied on stops silently once roughly nine journal exports exhaust the 25 MB per-app quota, so it cannot be treated as the restaurant's backup. BLOCKER remains wrong — a missing export is none of R13's three conditions. Full resolution in CONTRADICTIONS.md §6.

findings/S10_product.md § S10-F1 — A restaurant's entire configuration exists in one copy, on one tablet, with no export and no way back: $ grep -rniE "undo|confirm|backup|station|owner|assign|history|snooze|template|duplicate" lib/ | grep -viE ":[0-9]+: *(//|///)" lib/i18n.dart:124: 'backstopDown': '⚠️ Backup alarm unavailable — keep the app on screen' lib/ui/home.dart:194: if (!_foreground) return; // duplicate of the same transition $ grep -rn "exportCopy" lib/ lib/journal.dart:207: static Future<String?> exportCopy() async { lib/ui/modals.dart:701: final path = await Journal.exportCopy(); (proof/01_findings/S10/greps.txt §A, §B) findings/S13_data.md § S13-F4 — There is no export or import of timer configuration, so a replaced tablet loses the kitchen: proof/01_findings/S13/no_config_export_import.txt — the only share_plus call site in the whole app is lib/ui/modals.dart:705, inside _sendJournal, and the only dart:io File( sites in lib/ are lib/journal.dart:70,225 (the third grep hit, lib/ui/modals.dart:706, is XFile, a cross_file wrapper around the path exportCopy already returned). And proof/01_findings/S13/probe_data.txt, PROBE-E shows the configuration is already a clean, small, self-describing JSON document — 705 bytes for the whole seeded kitchen: TIMER_CONFIG_IS_STORED_AS_JSON=true TIMER_CONFIG_BYTES=705 [ { "id": "u7w2fw4acnl2", "name": "Manouche", "durationSec": 45, "sound": "Cascade", "phrase": "" }, … { "id": "r1ibznc1rpu0", "name": "Cook chicken", "durationSec": 810, "sound": "Cascade", "phrase": "", "steps": [ {"name":"Cook","sec":360}, {"name":"Flip","sec":90}, {"name":"Cook","sec":360} ] } ]

What to fix

findings/S13_data.md § S13-F4 — There is no export or import of timer configuration, so a replaced tablet loses the kitchen: R6 forbids new end-user features in this audit, so this is reported with a spec, not implemented. The spec: a Settings action that writes cadence-timers-v1 verbatim to <temp>/cadence-config-<device>-<date>.json and hands it to the same SharePlus path _sendJournal already uses, plus an import that validates through the existing TimerDef.fromJson and Store._readList salvage machinery (lib/engine/store.dart:91-113) before replacing. Both halves reuse code that already exists and is tested; neither needs a new dependency. Note the ordering constraint: an import that overwrites must not run through seedIfFresh's guard at :305-310, which exists precisely to stop a kitchen's config being replaced.

How to prove it

findings/S10_product.md § S10-F1 — A restaurant's entire configuration exists in one copy, on one tablet, with no export and no way back: a test that seeds a store with N timers, exports, clears every key, imports, and asserts byte-identical cadence-timers-v1 — red today because no export entry point exists. findings/S13_data.md § S13-F4 — There is no export or import of timer configuration, so a replaced tablet loses the kitchen: a store_test.dart round trip — seed a kitchen, export to a string, clear the store, import, and assert the resulting Engine.timers equals the original including chain steps and tones. Red today (the functions do not exist). ---

S10-F14 · What the operator experiences in each service failure, assuming the OS backstop does not ring

What is wrong

What the operator experiences in each service failure, assuming the OS backstop does not ring

File and line

lib/alarm_backstop.dart:99-116, :122-149, :177-221, :238-248; lib/ui/home.dart:174-204 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · agent_reports/S10_refute.md. Every row of the table checks out against the code, including the Wi-Fi row (no INTERNET permission in the manifest). Incomplete on one row: the _desired filter at :104 also excludes paused, and the table has no paused row — that gap is M1 below.

findings/S10_product.md § S10-F14 — What the operator experiences in each service failure, assuming the OS backstop does not ring: dart // lib/alarm_backstop.dart:103-106 final r = engine.run[t.id]; if (r == null || r.status != RunStatus.running || r.endsAt == null) { continue; } | Failure | What the operator loses | How they recover | Residual risk if the backstop is silent | |---|---|---|---| | Tablet knocked, app backgrounded | Nothing; every run mutation persists immediately (home.dart:252-258store.dart:151) and ringing dishes get an immediate notification (alarm_backstop.dart:238-248) | Reopen; tick() fires anything that expired (home.dart:188) | The dish rings only when someone reopens the app | | App killed by the OS while a dish is counting | Nothing persisted is lost; the AlarmManager alarm was already armed at deadline + 1.5 s | Relaunch; overdue runs fire on the first tick, 150 ms in | Silence until relaunch, and the count-up then reads +0:00 (S10-F6) | | App killed while a dish is ringing | The OS alarm for that dish was cancelled the moment it started ringing | Relaunch: nextVoiceAt is persisted, so the repeat re-fires | Nothing rings at all until someone opens the app — the single largest hole | | Force-stop (manufacturer task killer, user swipe on some skins) | Android's stopped state also stops the app's pending alarms | Manual relaunch only | Total silence, with no indication anything is wrong | | Device reboot | The board is blank until someone launches the app; scheduled notifications are re-registered by the boot receiver (AndroidManifest.xml, ScheduledNotificationBootReceiver) | Launch the app; absolute endsAt means nothing drifted | Silence for the whole reboot window | | Battery dies | Everything, until it is charged and rebooted. main.dart:28 holds the screen awake for the whole service with no battery indicator and no low-battery warning anywhere in lib/ | Charge, reboot, launch | Total silence; this is the most likely field failure of all | | Wi-Fi drops | Nothing. The app has no network code and requests no INTERNET permission (greps.txt §G: count 0); voice selection actively penalises network voices (L8) | n/a | None — this is a genuine strength and should be said out loud in the sales conversation |

What to fix

findings/S10_product.md § S10-F14 — What the operator experiences in each service failure, assuming the OS backstop does not ring: the ringing-state hole is defect repair, not a feature: _desired could keep a net armed for a run whose status is ringing, at its rangAt plus a grace, so a kill during the ring still produces a notification. The trade-off L11(a) protected against — a redundant notification popping over a live app — is handled by the same foreground cancel that already handles the running case.

How to prove it

findings/S10_product.md § S10-F14 — What the operator experiences in each service failure, assuming the OS backstop does not ring: extend test/backstop_test.dart with a case that sets a run to ringing and asserts a scheduled alarm still exists — red today, since _desired skips it at :104-106. ---

S10-F2 · Every destructive action in the product is one unconfirmed tap with no undo

What is wrong

Every destructive action in the product is one unconfirmed tap with no undo

File and line

lib/ui/tile.dart:618-625 (✕), lib/ui/modals.dart:348-351 (🗑); the HIGH is carried by lib/engine/engine.dart:378-380 (Save kills a live run and every batch of it) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · agent_reports/S10_refute.md §1, §3. "No confirmation, no undo" is CONFIRMED and total: grep -rniE "\bundo\b\|AlertDialog\|showDialog.*confirm" lib/ → exit 1, no output; grep -rn "onLongPress\|onDoubleTap" lib/ → exit 1, no output. "Four one-tap gestures" is REFUTED — only one of the four is one tap, and a different one touches the menu. A missing confirmation is none of R13's three BLOCKER conditions.

findings/S10_product.md § S10-F2 — Every destructive action in the product is one unconfirmed tap with no undo: dart // lib/engine/engine.dart:375-380 if (editingId != null) { final t = timers.where((x) => x.id == editingId).firstOrNull; if (t == null) return false; // vanished from under the open editor run.remove(t.id); // reset run state so it matches the new shape host.onStopped(t.id); removeClonesOf(t.id); // batches are tied to the def they were cloned from dart // lib/ui/modals.dart:348-351 _modalBtn('🗑', danger: true, onTap: () => Navigator.pop( context, TimerEditorResult(delete: true))),

What to fix

findings/S10_product.md § S10-F2 — Every destructive action in the product is one unconfirmed tap with no undo: none within R6 for the confirmations themselves (they are new UI). What is inside R6: saveDef currently discards the run of a dish whose timing did not change — an edit to name, tone or phrase alone does not require the run to be dropped, and preserving it in that case is defect repair, not a feature. Report the confirmation dialogs as the product decision; fix the unnecessary run-kill as a defect.

How to prove it

findings/S10_product.md § S10-F2 — Every destructive action in the product is one unconfirmed tap with no undo: a test that starts a dish, calls saveDef(editingId: id, name: 'X', phrase: '', sound: 'Bell', durationSec: <unchanged>), and asserts engine.run[id] != null — red today (engine.dart:378 removes it unconditionally).

S10-F3 · In edit mode, tapping a ringing tile opens the editor instead of silencing the alarm

What is wrong

In edit mode, tapping a ringing tile opens the editor instead of silencing the alarm

File and line

lib/ui/home.dart:349-353 (S10 cited :348-353), tested before r.status == RunStatus.ringing at :365; lib/ui/tile.dart:569-570 renders the ±/✕ row only for running/paused at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · agent_reports/S10_refute.md. editing defaults false (home.dart:40), is not persisted, and nothing auto-exits it — so the tile carries no escape while ringing. Severity stands.

findings/S10_product.md § S10-F3 — In edit mode, tapping a ringing tile opens the editor instead of silencing the alarm: dart // lib/ui/home.dart:348-353 void _tapTile(TimerDef t) { if (editing) { if (engine.isClone(t.id)) return; // clones: inert in edit mode _openEditor(t); return; } The ringing branch that would have stopped it sits below at :365-374 and is never reached. The tile's ✕ is not an escape either — tile.dart:569-570 renders the control row only for running or paused.

What to fix

findings/S10_product.md § S10-F3 — In edit mode, tapping a ringing tile opens the editor instead of silencing the alarm: treat a ringing run as taking precedence over edit mode inside _tapTile — move the r.status == RunStatus.ringing test above the editing test. This is defect repair, not a new capability.

How to prove it

findings/S10_product.md § S10-F3 — In edit mode, tapping a ringing tile opens the editor instead of silencing the alarm: a widget test that puts the board in edit mode with one ringing timer, taps the tile, and asserts engine.run[id] == null and no dialog on screen — red today.

S11-F1 · The shipped app contains three OFL-licensed font families with no copyright notice and no licence text

What is wrong

The shipped app contains three OFL-licensed font families with no copyright notice and no licence text

File and line

pubspec.yaml:48-69 (seven fonts declared); repository root (no licence file) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S11_asset_licensing.md · agent_reports/S11_refute.md §1. The factual predicate is wrong. All seven binaries ship the copyright notice in name ID 0/13, and DSEG7Classic-Bold.ttf ships the complete OFL 1.1 text inside name ID 13, byte-identical to the licensor's published copy. The real defect covers six files, not three families. Termination is further contested by the licensor's own FAQ 1.10. The release APK shipping no font notice is separately CONFIRMED, and LicenseRegistry/showLicensePage was proven from Flutter SDK source to surface nothing. Rewrite the finding before implementing.

findings/S11_asset_licensing.md § S11-F1 — The shipped app contains three OFL-licensed font families with no copyright notice and no licence text, which terminates the licence: $ git ls-files | grep -icE 'licen|notice|ofl|copying' 0 (proof/01_findings/S11/01_no_licence_files.txt) NOTICES.Z uncompressed chars: 1381653 NOTICES contains 'Big Shoulders' : False NOTICES contains 'Chivo' : False NOTICES contains 'DSEG' : False NOTICES contains 'Open Font License' : False (proof/01_findings/S11/09_apk_notices.txt, run against the release APK, all seven .ttf files confirmed present in the same archive) $ grep -rnE 'showLicensePage|showAboutDialog|AboutDialog|LicenseRegistry|LicensePage|addLicense' lib/ test/ android/ ios/ web/ grep exit=1 (1 = no match anywhere) (proof/01_findings/S11/11_no_licence_ui.txt) Licence text and TERMINATION clause: proof/01_findings/S11/bsd_OFL.txt, chivomono_OFL.txt, dseg046_DSEG-LICENSE.txt, all retrieved 2026-08-04 from the copyright holders' own distributions.

What to fix

findings/S11_asset_licensing.md § S11-F1 — The shipped app contains three OFL-licensed font families with no copyright notice and no licence text, which terminates the licence: commit findings/S11_LICENSES_draft.md as LICENSES.md at the repository root, and surface it in the app: register the three notices with LicenseRegistry.addLicense in main() and add a "Licences" row to the existing Settings dialog that opens showLicensePage. This is compliance plumbing, not a feature, so it is inside R6.

How to prove it

findings/S11_asset_licensing.md § S11-F1 — The shipped app contains three OFL-licensed font families with no copyright notice and no licence text, which terminates the licence: a test that asserts the notice reaches the shipped bundle and the registry. Red now, green after: dart test('font notices are registered', () async { final entries = await LicenseRegistry.licenses.toList(); final text = entries.expand((e) => e.paragraphs).map((p) => p.text).join(' '); for (final needle in ['Big Shoulders', 'Chivo', 'DSEG', 'Open Font License']) { expect(text, contains(needle)); } }); plus the artifact-level check, which is the one that actually matters: unzip -p build/app/outputs/flutter-apk/app-release.apk assets/flutter_assets/NOTICES.Z | gunzip | grep -c 'Open Font License' must be non-zero.

S12-F9 · After a reboot nothing brings the board back; only the OS notifications survive

What is wrong

After a reboot nothing brings the board back; only the OS notifications survive

File and line

android/app/src/main/AndroidManifest.xml:22-43 — the only activity, MAIN/LAUNCHER-only intent filter at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S12_lifecycle.md · agent_reports/S12_refute.md §6. Confirmed from the merged manifest read in full. One of the three S12 findings confirmed as written.

findings/S12_lifecycle.md § S12-F9 — After a reboot nothing brings the board back; only the OS notifications survive: the manifest, verbatim (proof/01_findings/S12/grep_boot_receiver_manifest.txt): 17: <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> 51: <action android:name="android.intent.action.BOOT_COMPLETED"/> 52: <action android:name="android.intent.action.MY_PACKAGE_REPLACED"/> 53: <action android:name="android.intent.action.QUICKBOOT_POWERON" /> 54: <action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/> and the plugin source that the receiver calls, ~/.pub-cache/hosted/pub.dev/flutter_local_notifications-22.1.0/android/src/main/java/com/dexterous/flutterlocalnotifications/FlutterLocalNotificationsPlugin.java:227-238: java static void rescheduleNotifications(Context context) { ArrayList<NotificationDetails> scheduledNotifications = loadScheduledNotifications(context); for (NotificationDetails notificationDetails : scheduledNotifications) { try { ... } else if (notificationDetails.timeZoneName != null) { zonedScheduleNotification(context, notificationDetails, false); Against that, the app's only activity declaration (android/app/src/main/AndroidManifest.xml:22-43) carries MAIN/LAUNCHER at :40-41 and nothing else. UNVERIFIED (R3): that this fires on a physical tablet has not been observed. It cannot be observed from this workstation — the baseline records flutter doctor as [✗] Android toolchain — Unable to locate Android SDK and no connected Android device (proof/00_baseline/SUMMARY.md §2). The exact protocol that would settle it: install the release build on the target tablet; start a 30-minute timer; adb shell dumpsys alarm | grep dev.sergemio.cadence and record the entry; reboot the tablet; without opening the app, re-run the same dumpsys and record whether the entry reappears; wait for the deadline and record whether the notification fires. Save all four captures. Treat the code comment at lib/alarm_backstop.dart:6 ("Scheduled alarms survive a device reboot (boot receiver)") as unverified until then.

What to fix

findings/S12_lifecycle.md § S12-F9 — After a reboot nothing brings the board back; only the OS notifications survive: report as a spec item (R6 — this is behaviour the business must decide on, not a defect repair): either add a first-party BOOT_COMPLETED receiver that starts MainActivity, or document that the tablet must be configured in Android's kiosk/device-owner mode with Cadence as the home app. The second costs no code and is what a fleet deployment wants anyway.

How to prove it

findings/S12_lifecycle.md § S12-F9 — After a reboot nothing brings the board back; only the OS notifications survive: the four-capture dumpsys/reboot protocol above, plus a fifth capture showing the Cadence board on screen after the reboot with no human interaction. ---

S12R-F3 · S12's own `Backstop.dispose()` fix leaves the OS safety net on a **stale** deadline

What is wrong

S12's own Backstop.dispose() fix leaves the OS safety net on a stale deadline

File and line

The patch's second hunk, against lib/alarm_backstop.dart:266; the policy it contradicts is at lib/alarm_backstop.dart:240-242 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S12_refute.md §2.1 · proof/01_findings/S12_refute/r6_backstop_dispose_WITH_s12fix.txt. Measured both ways. Unpatched, a teardown inside the 300 ms debounce window leaves the OS holding the new deadline — correct. With S12's patch applied it holds the stale one, ten seconds later than the cook asked for. That is the BLOCKER class of failure traded for a duplicate notification that was never wrong. The correct dispose() is _flushSchedules(); _debounce?.cancel(); _debounce = null; _pending = null; — the same policy the class already applies at onBackground().

Replay the named proof at agent_reports/S12_refute.md §2.1 · proof/01_findings/S12_refute/r6_backstop_dispose_WITH_s12fix.txt against The patch's second hunk, againstlib/alarm_backstop.dart:266; the policy it contradicts is atlib/alarm_backstop.dart:240-242` on a disposable copy of03a176e72ef0075eec86b8915cbe6e93042a3b9d`.

What to fix

CORRECTED FIX: disposal must first call _flushSchedules(), then cancel and clear _debounce and _pending; never discard a committed deadline update. RED/GREEN TEST: change a deadline by ten seconds, dispose inside the 300 ms debounce window, and assert the OS request holds the new deadline, not the stale one.

How to prove it

agent_reports/S12_refute.md § S12R-F3 — S12's Backstop.dispose() leaves the OS safety net on a stale deadline: proof/01_findings/S12_refute/tests/s12r_backstop_dispose_discards_test.dart, test "B1". Change its final expectation to expect(scheduled.length, 2) and assert the second armed instant is 10,000 ms below the first. Red with S12's patch as written, green with the flush added.

S13-F1 · The exported journal carries operator free text and a device description off the device, unredacted

What is wrong

The exported journal carries operator free text and a device description off the device, unredacted

File and line

lib/journal.dart:205-237 (export) and lib/ui/modals.dart:696-717 (share) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S13_data.md · agent_reports/S13_refute.md. Confirmed by S13's refuter, and the export path is independently verified by S5's refuter for a different defect (modals.dart:698-717 and journal.dart:233-236 verbatim-exact). Related: S10-F12 (the journal is the operator's only record and it is French-only).

findings/S13_data.md § S13-F1 — The exported journal carries operator free text and a device description off the device, unredacted: the real generated payload, proof/01_findings/S13/probe_data.txt, PROBE-C (EXPORT_BYTES=1157 for this short session): ================================================ SESSION 2026-08-04 12:09:22.619 · Cadence v0.4.12 APPAREIL macos !! SESSION PRECEDENTE TUEE — derniere trace 2026-08-04 12:02:22.618 (silence de 7 min 0 s avant ce demarrage). L'app ne s'est PAS fermee proprement. ================================================ 12:09:22.619 demarrage charge: 7 timers, 0 en cours, langue=fr 12:09:22.619 ecran maintien allume actif (wakelock) 12:09:22.619 secours alarmes systeme actives 12:09:22.619 CREATION "Frites Jean-Marc" — 90 s · sonnerie=Bell · annonce="Table 12 pour Mme Dupont" 12:09:22.619 depart Frites Jean-Marc 90 s 12:09:22.619 ALARME Frites Jean-Marc sonne — decalage 12 ms · a l'ecran · sonnerie=Bell 12:09:22.619 parole "Table 12 pour Mme Dupont" 12:09:22.619 ARRET Frites Jean-Marc alarme coupee apres 4.2 s 12:09:22.619 reglage langue -> fr (voix fr-FR) 12:09:22.619 reglage volume -> 60% (appareil) 12:09:22.619 SUPPRESSION timer "Frites Jean-Marc" 12:09:22.620 !! PANNE CRITIQUE voice-init: engine dead 12:09:22.620 · battement timers=7 actifs=1 sonnent=0 lots=1 a l'ecran 12:09:22.620 EXPORT journal extrait depuis les reglages The share call itself (lib/ui/modals.dart:705-711), verbatim: dart await SharePlus.instance.share(ShareParams( files: [XFile(path)], subject: 'Cadence log — ${Journal.device.split('·').first.trim()} — ' '${p(now.day)}/${p(now.month)} ${p(now.hour)}h${p(now.minute)}', text: 'Journal de bord Cadence v${Journal.appVersion}\n' 'Appareil : ${Journal.device}\n', )); What leaves the device, in what format, to where, at whose initiation: a plain-text .txt file plus a subject and body, handed to the Android/iOS share sheet, only when the operator taps the button (lib/ui/modals.dart:678-681). Cadence chooses no destination and performs no upload; the destination is whatever app the operator picks. Cadence never transmits it itself (§3).

What to fix

findings/S13_data.md § S13-F1 — The exported journal carries operator free text and a device description off the device, unredacted: this is compliance plumbing, in scope under R6. Two changes: (a) show the operator what is about to be sent — a one-line count and the first and last timestamps — before the share sheet opens; (b) add a redaction option that replaces timer names with their stable ids in the exported copy, keeping the diagnostic value (drift, kills, alarm timings) while removing the free text. Do not remove the journal; research/01_prior_work.md L13 establishes it as the deliverable of the tablet campaign.

How to prove it

findings/S13_data.md § S13-F1 — The exported journal carries operator free text and a device description off the device, unredacted: a journal_test.dart case that logs a line containing Table 12 pour Mme Dupont, calls exportCopy(redact: true), and asserts the exported file does not contain that string while still containing ALARME and the drift figure. Red today — the parameter does not exist and PROBE-C shows the string present. ---

S13-F2 + S9-F10 + M4 (S10's refuter) · Android auto-backup is on by default, so the journal, the kitchen's entire menu and every preference are copied into the operator's Google account and restored onto any device

What is wrong

Android auto-backup is on by default, so the journal, the kitchen's entire menu and every preference are copied into the operator's Google account and restored onto any device

File and line

android/app/src/main/AndroidManifest.xml:18-21 — the <application> element (S13 cited :20-23; :22-23 are <activity / android:name=".MainActivity") declares only label, name and icon; no android:allowBackup, no android:dataExtractionRules, no android:fullBackupContent at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S13_data.md · findings/S9_platform_config.md · agent_reports/S13_refute.md · agent_reports/S10_refute.md §6 M4 · agent_reports/S9_refute.md. Found three times, from the data-privacy, platform-config and product sides, and refuted twice. S9's refuter proved the absence from its own merged manifest (0 occurrences) and confirmed MEDIUM for the config-gap framing; S13's refuter confirmed HIGH for the data-exposure framing. Take HIGH — the two grades are the same omission read as a configuration gap and as a privacy exposure, and the exposure framing is the one with the measured consequence (S13-R2). S13-R3 REFUTES S13's reasoning: Google's Data Safety guidance defines "collect" as transmitting data off a user's device by the developer, and explicitly exempts a user's own backup to their own cloud account. Following S13-F2's rationale would produce a false store filing on both stores. State the fact; do not declare it as collection.

findings/S13_data.md § S13-F2 — Android auto-backup is on by default and copies the journal and every preference into the operator's Google account: proof/01_findings/S13/apk_permissions.txt — read from the shipped app-release.apk (SHA-256 f11a484d821ed4ab11121ea01f7291841dad320def22cbf9b8f9f61c77e7ca9e): b'allowBackup' in manifest -> False b'a\x00l\x00l\x00o\x00w\x00B\x00a\ in manifest -> False (UTF-16 form) b'dataExtractionRules' in manifest -> False and the merged Gradle release manifest shows the <application> element carrying only android:name, android:appComponentFactory, android:extractNativeLibs, android:icon, android:label. The default, verbatim from developer.android.com (capture proof/03_market/captures/s13_android_allowbackup.txt, URL https://developer.android.com/guide/topics/manifest/application-element, retrieved 2026-08-04): > android:allowBackup — Whether to let the application participate in the backup and restore > infrastructure. … The default value of this attribute is "true". > > Note: For apps targeting Android 12 (API level 31) or higher, this behavior varies. On devices > from some device manufacturers, you can't disable device-to-device migration of your app's files. The app targets API 36 (uses-sdk android:targetSdkVersion="36" in the merged manifest), so it is in that second category. What auto-backup takes, verbatim from developer.android.com (capture proof/03_market/captures/s13_android_autobackup.txt, URL https://developer.android.com/identity/data/autobackup, retrieved 2026-08-04): > Files saved to your app's internal storage and accessed by getFilesDir() or getDir(String, int) > > … sharedpref: the directory where SharedPreferences are stored. > > Auto Backup excludes files in directories returned by getCacheDir(), getCodeCacheDir(), and > getNoBackupFilesDir(). The journal lives in getDir("flutter", MODE_PRIVATE)path_provider_android resolves getApplicationDocumentsDirectory() there (path_provider_android-2.3.1/lib/src/path_provider_android_real.dart:36-46, quoted in proof/01_findings/S13/transitive_http_reachability.txt context) — which is the exact API the Android documentation names as included. Net effect: all 11 preference keys, any .corrupt sibling, and the whole cadence-journal.txt, including every operator free-text field, are uploaded to the tablet owner's Google Drive backup. Only the exported copy in the temp directory escapes it, because getTemporaryDirectory() maps to cacheDir. findings/S9_platform_config.md § S9-F10 — android:allowBackup and android:dataExtractionRules are undeclared, so the timer run-state is cloud-backed-up and device-transferred by default: Verbatim, android/app/src/main/AndroidManifest.xml:18-21 — the complete <application> opening tag: xml <application android:label="Cadence" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> Confirmed absent after manifest merge, in the packaged release manifest (build/app/intermediates/packaged_manifests/release/processReleaseManifestForPackage/AndroidManifest.xml:60-65): xml <application android:name="android.app.Application" android:appComponentFactory="androidx.core.app.CoreComponentFactory" android:extractNativeLibs="false" android:icon="@mipmap/ic_launcher" android:label="Cadence" > Neither android:allowBackup nor android:dataExtractionRules nor android:fullBackupContent appears. The persisted key whose restore is dangerous, verbatim from lib/engine/models.dart:97 and lib/engine/store.dart:16: RunEntry.endsAt is an absolute epoch-millisecond deadline stored under cadence-run-v1. Source for the default and the mechanism: Android Auto Backup (https://developer.android.com/guide/topics/data/autobackup, retrieved 2026-08-04, captures/android_allowbackup_ref.txt). agent_reports/S10_refute.md § M4 — Android Auto Backup is on by default and is nowhere in the product's own account of its data: the <application> element of the manifest carries android:label, android:name and android:icon and no backup attribute of any kind. Official behaviour: "Apps that target Android 6.0 (API level 23) or higher automatically participate in Auto Backup… The default value is true, but we recommend explicitly setting the attribute in your manifest"; the included set lists "sharedpref: the directory where SharedPreferences are stored"; backups go "to the user's Google Drive" with "up to 25 MB of backup data per app user", and occur when "the user has enabled backup on the device… at least 24 hours have elapsed… the device is idle… the device is connected to a Wi-Fi network" — https://developer.android.com/identity/data/autobackup, retrieved 2026-08-04, capture at proof/01_findings/S10_refute/captures/android_autobackup.txt. agent_reports/S13_refute.md § S13-R2 — S13-F5 and S13-F2 compound into silent loss of the only configuration backup: lib/journal.dart:25-26: dart static const _maxBytes = 3 * 1024 * 1024; // rotate above 3 MB static const _keepBytes = 1024 * 1024; // keep the last 1 MB and lib/journal.dart:227-231: dart // best-effort USB-reachable copy; never blocks the share try { final ext = await getExternalStorageDirectory(); if (ext != null) await _file!.copy('${ext.path}/$name'); } catch (_) {} with the filename carrying date and time (:221-223), so each export creates a new file. Android's own documentation (capture proof/03_market/captures/s813r_android_autobackup.txt, URL https://developer.android.com/identity/data/autobackup, retrieved 2026-08-04): > By default, Auto Backup includes files in most of the directories that are assigned to your app by > the system: Shared preferences files … Files saved to your app's internal storage and accessed by > getFilesDir() or getDir(String, int)Files on external storage in the directory returned by > getExternalFilesDir(String) > Backup data is stored in a private folder in the user's Google Drive account, limited to 25 MB per > app. … Caution: If the amount of data is over 25 MB, the system calls onQuotaExceeded() and > doesn't back up data to the cloud. agent_reports/S13_refute.md § S13-R3 — S13-F2's store-declaration rationale is wrong, and following it would over-declare on both stores: capture proof/03_market/captures/s813r_play_data_safety_expanded.txt, URL https://support.google.com/googleplay/android-developer/answer/10787469?hl=en, retrieved 2026-08-04, §"Data collection": > "Collect" means transmitting data from your app off a user's device. > Note: Developers do not have to declare data access as collection if it occurs solely on the user's > device as long as the data is never transmitted off the user's device. and, on the exact shape of the backup case: > My app enables users to upload their data directly to Google Drive or Dropbox for backup or > storage. My app does not access any of this data. Should that still be disclosed as "collection"? > It depends on the particular implementation. If the user chooses to upload their data directly to > their own external drive or cloud storage account (such as Google Drive, Dropbox, or similar > services) and this upload is governed by the external drive or cloud storage provider's terms of > service and privacy policy, and your app never collects or accesses the data in question, then your > app does not need to declare the collection of this data. Apple's definition is quoted in §1 above and gives the same answer. Auto Backup is performed by the Android OS into the tablet owner's own Google account under Google's terms; Serge never receives or accesses it. Neither is the share-sheet export, whose destination the operator chooses. agent_reports/S2_refute.md § S2R-M4 — The .corrupt backup write is itself unverified and non-critical, so the "never silently lost" guarantee can fail silently: with a platform store that refuses writes to cadence-timers-v1.corruptM4 backup present=false | scopes=[load-cadence-timers-v1, save-set-cadence-timers-v1.corrupt] | critical={load-cadence-timers-v1} save-set-cadence-timers-v1.corrupt is absent from critical.

What to fix

findings/S13_data.md § S13-F2 — Android auto-backup is on by default and copies the journal and every preference into the operator's Google account: compliance plumbing, in scope under R6. Add to the <application> element: android:allowBackup="false" — or, if the timer configuration should survive a device swap, android:dataExtractionRules="@xml/backup_rules" with the journal excluded and only cadence-timers-v1 included. The second option also happens to be the cheapest partial answer to S13-F4. Decide which; do not ship the current silent default. findings/S9_platform_config.md § S9-F10 — android:allowBackup and android:dataExtractionRules are undeclared, so the timer run-state is cloud-backed-up and device-transferred by default: Declare the policy explicitly on the <application> element rather than inheriting it. Either android:allowBackup="false" if restored timer state is not wanted, or android:allowBackup="true" together with an android:dataExtractionRules XML that excludes cadence-run-v1 and cadence-clones-v1 from both <cloud-backup> and <device-transfer> while keeping cadence-timers-v1, cadence-lang and cadence-vol — so a restored device keeps the chef's timer definitions and loses only the transient run state. The second is the better product behaviour and is still a configuration change, not a feature. agent_reports/S10_refute.md § M4 — Android Auto Backup is on by default and is nowhere in the product's own account of its data: set android:allowBackup explicitly, in whichever direction Serge chooses, per the platform's own recommendation. Compliance plumbing, in scope under R6. agent_reports/S13_refute.md § S13-R2 — S13-F5 and S13-F2 compound into silent loss of the only configuration backup: delete previous cadence-log-*.txt files in the external directory before writing a new one, keeping at most the newest (S13-F5's own fix — this finding raises its priority). Then, when fixing S13-F2, use android:dataExtractionRules that include cadence-timers-v1 and exclude the journal and the external directory, rather than allowBackup="false", so config recovery survives and the quota stops being consumed by transcripts. agent_reports/S13_refute.md § S13-R3 — S13-F2's store-declaration rationale is wrong, and following it would over-declare on both stores: rewrite the "Why it matters" paragraph; keep the fix (dataExtractionRules over allowBackup="false", per S13-R2). Correct §1's framing to "GDPR controller inventory", and add one line recording that both store forms answer "no data collected", with the two definitions quoted. agent_reports/S2_refute.md § S2R-M4 — The .corrupt backup write is itself unverified and non-critical, so the "never silently lost" guarantee can fail silently: give _guard an {bool isCritical = false} parameter (S2-F10 proposes the same signature change for the one-shot flags) and pass isCritical: true for the .corrupt sibling write at store.dart:123.

How to prove it

findings/S13_data.md § S13-F2 — Android auto-backup is on by default and copies the journal and every preference into the operator's Google account: rebuild the release APK and re-run the manifest dump in proof/01_findings/S13/apk_permissions.txt. It prints b'allowBackup' in manifest -> False today; after the fix it prints True and the merged manifest shows android:allowBackup="false". --- findings/S9_platform_config.md § S9-F10 — android:allowBackup and android:dataExtractionRules are undeclared, so the timer run-state is cloud-backed-up and device-transferred by default: An assertion over the merged manifest: grep -c 'android:allowBackup' build/app/intermediates/packaged_manifests/release/processReleaseManifestForPackage/AndroidManifest.xml returns 0 now and ≥ 1 after; plus a Store unit test that loads a cadence-run-v1 blob whose endsAt is a week in the past and asserts the resulting engine state contains no ringing entries. --- agent_reports/S10_refute.md § M4 — Android Auto Backup is on by default and is nowhere in the product's own account of its data: grep the built manifest for the attribute; it is absent today. --- agent_reports/S13_refute.md § S13-R2 — S13-F5 and S13-F2 compound into silent loss of the only configuration backup: extend test/journal_test.dart with a _FakePaths overriding getExternalStoragePath(), call exportCopy() three times, and assert the external directory holds at most one cadence-log-*.txt. Red today (three files). agent_reports/S13_refute.md § S13-R3 — S13-F2's store-declaration rationale is wrong, and following it would over-declare on both stores: not a code test — the two quoted definitions above are the artifact. agent_reports/S2_refute.md § S2R-M4 — The .corrupt backup write is itself unverified and non-critical, so the "never silently lost" guarantee can fail silently: my test "M4 — the .corrupt backup write is itself unverified…". Invert the last expectation to contains('save-set-cadence-timers-v1.corrupt'). Red now, green after. ---

S13-F5 · The journal writes an unmanaged second copy to external storage that nothing ever deletes

What is wrong

The journal writes an unmanaged second copy to external storage that nothing ever deletes

File and line

lib/journal.dart:228-231 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S13_data.md · agent_reports/S13_refute.md §3 S13-R2. Raised by its own refuter. The unmanaged copies are not merely clutter: they sit in a directory Android backs up by default, each is 1–3 MB, and roughly nine of them exhaust the 25 MB per-app Auto Backup quota, at which point cloud backup stops silently for the whole app (S13-R2). The surrounding block (:226, :231) is independently verified verbatim by S5's refuter for S5-F10.

findings/S13_data.md § S13-F5 — The journal writes an unmanaged second copy to external storage that nothing ever deletes: proof/01_findings/S13/no_config_export_import.txt, section 3, quotes the block verbatim; proof/01_findings/S13/probe_data.txt, PROBE-D shows one export producing one dated file alongside the primary journal: AFTER_EXPORT_FILES=[cadence-journal.txt, cadence-log-macos-2026-08-04-12h09.txt] The comment at :227 states the intent — a copy reachable over USB — which is exactly why it sits outside private storage. agent_reports/S13_refute.md § S13-R2 — S13-F5 and S13-F2 compound into silent loss of the only configuration backup: lib/journal.dart:25-26: dart static const _maxBytes = 3 * 1024 * 1024; // rotate above 3 MB static const _keepBytes = 1024 * 1024; // keep the last 1 MB and lib/journal.dart:227-231: dart // best-effort USB-reachable copy; never blocks the share try { final ext = await getExternalStorageDirectory(); if (ext != null) await _file!.copy('${ext.path}/$name'); } catch (_) {} with the filename carrying date and time (:221-223), so each export creates a new file. Android's own documentation (capture proof/03_market/captures/s813r_android_autobackup.txt, URL https://developer.android.com/identity/data/autobackup, retrieved 2026-08-04): > By default, Auto Backup includes files in most of the directories that are assigned to your app by > the system: Shared preferences files … Files saved to your app's internal storage and accessed by > getFilesDir() or getDir(String, int)Files on external storage in the directory returned by > getExternalFilesDir(String) > Backup data is stored in a private folder in the user's Google Drive account, limited to 25 MB per > app. … Caution: If the amount of data is over 25 MB, the system calls onQuotaExceeded() and > doesn't back up data to the cloud.

What to fix

findings/S13_data.md § S13-F5 — The journal writes an unmanaged second copy to external storage that nothing ever deletes: before writing the new external copy, delete previous cadence-log-*.txt files in that directory, keeping at most the newest one. Alternatively gate the external copy behind a debug/pilot flag so it does not ship to restaurants at all. Either is a few lines inside the existing try block. agent_reports/S13_refute.md § S13-R2 — S13-F5 and S13-F2 compound into silent loss of the only configuration backup: delete previous cadence-log-*.txt files in the external directory before writing a new one, keeping at most the newest (S13-F5's own fix — this finding raises its priority). Then, when fixing S13-F2, use android:dataExtractionRules that include cadence-timers-v1 and exclude the journal and the external directory, rather than allowBackup="false", so config recovery survives and the quota stops being consumed by transcripts.

How to prove it

findings/S13_data.md § S13-F5 — The journal writes an unmanaged second copy to external storage that nothing ever deletes: extend test/journal_test.dart with a _FakePaths that also overrides getExternalStoragePath(), call exportCopy() three times, and assert the external directory holds at most one cadence-log-*.txt. Red today (three files). --- agent_reports/S13_refute.md § S13-R2 — S13-F5 and S13-F2 compound into silent loss of the only configuration backup: extend test/journal_test.dart with a _FakePaths overriding getExternalStoragePath(), call exportCopy() three times, and assert the external directory holds at most one cadence-log-*.txt. Red today (three files).

S13-R2 (S13's refuter) · S13-F5 and S13-F2 compound into silent loss of the only configuration backup: roughly nine journal exports blow Android's 25 MB Auto Backup quota and cloud backup stops, unannounced

What is wrong

S13-F5 and S13-F2 compound into silent loss of the only configuration backup: roughly nine journal exports blow Android's 25 MB Auto Backup quota and cloud backup stops, unannounced

File and line

lib/journal.dart:25-26 (_maxBytes = 3 MB, _keepBytes = 1 MB), :221-231 (the export copy) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S13_refute.md §3. Three facts each present in S13's report and never multiplied: (a) every export writes a new dated copy into getExternalStorageDirectory() and nothing deletes any of them; (b) that directory is Context.getExternalFilesDir(null), which Android's Auto Backup documentation lists among the directories it backs up by default; (c) Auto Backup is capped at 25 MB per app, past which onQuotaExceeded() fires and the system stops backing the app up entirely. Each rotated copy is 1–3 MB. A pilot kitchen exporting daily crosses the quota inside a fortnight — and this is the evidence that settles the S10-F1 / S13-F4 severity dispute (CONTRADICTIONS.md §6): the platform default S10's refuter relied on is not dependable.

agent_reports/S13_refute.md § S13-R2 — S13-F5 and S13-F2 compound into silent loss of the only configuration backup: lib/journal.dart:25-26: dart static const _maxBytes = 3 * 1024 * 1024; // rotate above 3 MB static const _keepBytes = 1024 * 1024; // keep the last 1 MB and lib/journal.dart:227-231: dart // best-effort USB-reachable copy; never blocks the share try { final ext = await getExternalStorageDirectory(); if (ext != null) await _file!.copy('${ext.path}/$name'); } catch (_) {} with the filename carrying date and time (:221-223), so each export creates a new file. Android's own documentation (capture proof/03_market/captures/s813r_android_autobackup.txt, URL https://developer.android.com/identity/data/autobackup, retrieved 2026-08-04): > By default, Auto Backup includes files in most of the directories that are assigned to your app by > the system: Shared preferences files … Files saved to your app's internal storage and accessed by > getFilesDir() or getDir(String, int)Files on external storage in the directory returned by > getExternalFilesDir(String) > Backup data is stored in a private folder in the user's Google Drive account, limited to 25 MB per > app. … Caution: If the amount of data is over 25 MB, the system calls onQuotaExceeded() and > doesn't back up data to the cloud.

What to fix

agent_reports/S13_refute.md § S13-R2 — S13-F5 and S13-F2 compound into silent loss of the only configuration backup: delete previous cadence-log-*.txt files in the external directory before writing a new one, keeping at most the newest (S13-F5's own fix — this finding raises its priority). Then, when fixing S13-F2, use android:dataExtractionRules that include cadence-timers-v1 and exclude the journal and the external directory, rather than allowBackup="false", so config recovery survives and the quota stops being consumed by transcripts.

How to prove it

agent_reports/S13_refute.md § S13-R2 — S13-F5 and S13-F2 compound into silent loss of the only configuration backup: extend test/journal_test.dart with a _FakePaths overriding getExternalStoragePath(), call exportCopy() three times, and assert the external directory holds at most one cadence-log-*.txt. Red today (three files).

S14-F1 · A failure inside `Store.open()` kills the app before `runApp`: the tablet shows a blank window forever, nothing logged, nothing on screen

What is wrong

A failure inside Store.open() kills the app before runApp: the tablet shows a blank window forever, nothing logged, nothing on screen

File and line

lib/main.dart:22-35, specifically :24 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S14_entry_unowned.md · agent_reports/S14_refute.md §2.1. Test T1 re-run green; realism established from the Android plugin's Kotlin source; the blank-window claim verified against the Flutter engine's own FlutterActivity.java, i.e. reachable without mocking.

findings/S14_entry_unowned.md § S14-F1 — A failure inside Store.open() kills the app before runApp: the tablet shows a blank window forever, with nothing logged, nothing on screen, and no way for the operator to know why: verbatim source — dart Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final store = await Store.open(); await Journal.init(store.prefs, kAppVersion); (lib/main.dart:22-25) and dart static Future<Store> open() async => Store(await SharedPreferences.getInstance()); (lib/engine/store.dart:29-30) Executable proof — test T1 in proof/01_findings/S14/s14_main_test.dart, run recorded in proof/01_findings/S14/probe_main_green.txt (00:00 +4: All tests passed!, EXIT_CODE=0). T1 installs a mock handler on MethodChannel('plugins.flutter.io/shared_preferences') that throws PlatformException(code: 'storage_unavailable'), calls the real main(), and asserts: dart expect(thrown, isA<PlatformException>(), reason: 'main() has no try around Store.open(); the error escapes'); expect(find.byType(app.CadenceApp), findsNothing); expect(find.byType(HomeScreen), findsNothing); expect(find.byType(MaterialApp), findsNothing); expect(Diag.log, isEmpty, reason: 'no Diag entry is produced by a failed Store.open()'); expect(Diag.critical.value, isEmpty, reason: 'no critical scope, therefore no operator banner'); All five assertions hold. T2 in the same file is the control: the identical Store.open() call succeeds when the store is available, so the exception in T1 is the storage failure and nothing else in main().

What to fix

findings/S14_entry_unowned.md § S14-F1 — A failure inside Store.open() kills the app before runApp: the tablet shows a blank window forever, with nothing logged, nothing on screen, and no way for the operator to know why: wrap the two awaited boot calls and always reach runApp. No new user feature — it is defect repair and reuses the operator banner that already exists: dart Store? store; try { store = await Store.open(); } catch (e) { Diag.fail('boot-store', e, isCritical: true); } then, when store == null, runApp a minimal failure screen carrying the existing i18n.call('loadFail') string (lib/i18n.dart:75-76, :121-122) plus the exception text, so the cook sees a message and the next engineer sees a cause. Journal.init already never throws by design (lib/journal.dart:64, "Never throws: a broken journal must never take the app down with it") and does not need the same treatment — but it must be called on both branches so the failure itself is recorded.

How to prove it

findings/S14_entry_unowned.md § S14-F1 — A failure inside Store.open() kills the app before runApp: the tablet shows a blank window forever, with nothing logged, nothing on screen, and no way for the operator to know why: T1 as written goes green today because it asserts the broken behaviour. Invert it: expect(find.textContaining(<the failure string>), findsOneWidget) after await main() with the throwing store mock. That assertion fails at 03a176e (nothing is rendered — proven above by findsNothing on MaterialApp) and passes after the fix. ---

S14-F2 · Both awaited startup calls can hang forever with no timeout and no watchdog, producing the same blank window with no exception to catch

What is wrong

Both awaited startup calls can hang forever with no timeout and no watchdog, producing the same blank window with no exception to catch

File and line

lib/main.dart:24-25 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S14_entry_unowned.md · agent_reports/S14_refute.md §2.2. S14 argued from absence. The refuter executed it: flutter test killed at 90 s wall clock, EXIT_CODE=124.

findings/S14_entry_unowned.md § S14-F2 — Both awaited startup calls can hang forever with no timeout and no watchdog, producing the same blank window with no exception to catch: .timeout( appears exactly once in the whole of lib/, and it is not in the boot path — lib/audio/voice.dart:168, a 12-second timeout on the speak invocation (research/00_code_map.md §3.3, ".timeout(...): one site"). lib/main.dart verbatim contains no try, no timeout, and one error handler, the .catchError at :30 on the unawaited wakelock chain (research/00_code_map.md §3.3, row 1: lib/main.dart:30 is the file's only entry in the 38-site error-handling inventory).

What to fix

findings/S14_entry_unowned.md § S14-F2 — Both awaited startup calls can hang forever with no timeout and no watchdog, producing the same blank window with no exception to catch: put a bounded wait on each boot call and always reach runApp: await Store.open().timeout(const Duration(seconds: 5)) and await Journal.init(...).timeout(const Duration(seconds: 5)), both inside the F1 try, with the TimeoutException routed to Diag.fail('boot-store' | 'boot-journal', e, isCritical: true). The 5-second figure is not a magic number: it must be shorter than the operator's tolerance for a black screen and longer than a cold prefs read on the known field device (Lenovo TB-8505F, Android 10, named in commit f46d142), and it should be pinned by the same constant in both places.

How to prove it

findings/S14_entry_unowned.md § S14-F2 — Both awaited startup calls can hang forever with no timeout and no watchdog, producing the same blank window with no exception to catch: a test that installs a mock handler on MethodChannel('plugins.flutter.io/shared_preferences') returning a Completer that is never completed, then asserts main() completes and the failure screen is rendered within the fake-async budget. At 03a176e that test hangs and is reported as a timeout by flutter test; after the fix it passes. ---

S2-F4 · A timer created after v0.4.11 is forced to `Bell` if the migration ever re-runs

What is wrong

A timer created after v0.4.11 is forced to Bell if the migration ever re-runs

File and line

lib/engine/store.dart:273-279 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · proof/01_findings/S2_refute/. The loop has no legacyZoneId == null guard. Unlike S2-F3 this is reachable through the S2-F2 kill window and needs no wrong-typed value.

findings/S2_persistence.md § S2-F4 — A timer created after v0.4.11 is forced to Bell if the migration ever re-runs: recorded run proof/01_findings/S2/02_migration.txt: DEFECT 4 — a v0.4.11+ timer carried through the migration is forced to Bell sounds after migration: {Fries: Beep, Baklava: Bell} Baklava was seeded with 'sound': 'Cascade' and no zoneId.

What to fix

findings/S2_persistence.md § S2-F4 — A timer created after v0.4.11 is forced to Bell if the migration ever re-runs: the if (zid == null) continue; guard in S2-F2's fix resolves this finding too.

How to prove it

findings/S2_persistence.md § S2-F4 — A timer created after v0.4.11 is forced to Bell if the migration ever re-runs: s2_migration_test.dart, test "DEFECT 4"; change the final expectation to expect(_sounds(e)['Baklava'], 'Cascade'). Red now, green after. ---

S2-F5 + S5-F4 · A failed journal write discards the buffered lines permanently and tells nobody; `Journal.ready` keeps returning true

What is wrong

A failed journal write discards the buffered lines permanently and tells nobody; Journal.ready keeps returning true

File and line

lib/journal.dart:163-180, specifically :167-168 (the _buf.clear() before the write) and :175-177 (debugPrint only); ready => _file != null at :53; Settings block stays up at lib/ui/modals.dart:673 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · findings/S5_error_handling.md · agent_reports/S2_refute.md · agent_reports/S5_refute.md. Found twice. S2 filed the data-loss half at HIGH and reproduced it (probe J1: the two lines logged during the outage are absent, Diag.log=0, Journal.ready=true). S5 filed the reporting half at MEDIUM and its refuter recorded that S5 missed the data loss sitting in the same six lines, and that S5's proposed fix ("stamp !! ECRITURE JOURNAL IMPOSSIBLE into the buffer for the next flush") is not implementable — by the time the handler runs the buffer is already cleared. Take S2's grade and drop S5's fix.

findings/S2_persistence.md § S2-F5 — A failed journal write discards the buffered lines permanently and tells nobody: the code, verbatim — dart // journal.dart:166-177 if (_buf.isEmpty) return; final chunk = List<String>.from(_buf); _buf.clear(); // <- cleared BEFORE the write try { await _file!.writeAsString('${chunk.join('\n')}\n', mode: FileMode.append, flush: true); await _prefs?.setInt(_kLastBeat, DateTime.now().millisecondsSinceEpoch); } catch (e) { debugPrint('[cadence] journal write failed: $e'); // <- chunk is discarded } Recorded run proof/01_findings/S2/03_journal_diagnostics.txt: J1 DEFECT — a failed journal write DISCARDS the buffered lines permanently after failed writes: Diag.log=0 entries, Journal.ready=true recovered file: 12:03:38.792 reprise stockage revenu The two lines logged during the outage — ALARME Fries and ARRET Fries — never appear. findings/S5_error_handling.md § S5-F4 — Journal write failures are reported to nobody, and Journal.ready keeps saying yes: verbatim, lib/journal.dart:169-177: dart try { await _file!.writeAsString('${chunk.join('\n')}\n', mode: FileMode.append, flush: true); // "last known alive" moves with every write, so a kill is dated to the // last event rather than to the last 60s beat await _prefs?.setInt(_kLastBeat, DateTime.now().millisecondsSinceEpoch); } catch (e) { debugPrint('[cadence] journal write failed: $e'); } Forced to fire by replacing the journal file with a directory of the same name — test S5-F4 … a write that fails is reported to NOBODY, and ready stays true (proof/01_findings/S5/01_s5_tests_baseline.txt): [cadence] journal write failed: FileSystemException: Cannot open file, … (OS Error: Is a directory, errno = 21) with Journal.ready == true, Diag.log empty and Diag.critical.value empty. In a release build debugPrint goes to the platform log, which nobody reads on a kitchen tablet.

What to fix

findings/S2_persistence.md § S2-F5 — A failed journal write discards the buffered lines permanently and tells nobody: on failure, re-insert the chunk at the head of _buf (_buf.insertAll(0, chunk)) up to a bounded retry budget, and route the failure through Diag.fail('journal-write', e, isCritical: true) so the operator banner lights. To avoid the recursion Diag.fail → Journal.log → _flush, guard with a static bool _reportingWriteFailure re-entrancy flag. findings/S5_error_handling.md § S5-F4 — Journal write failures are reported to nobody, and Journal.ready keeps saying yes: add static bool writeFailed = false; set in the handler, and one-shot debugPrint-plus-in-memory record via Diag.log.add(DiagEntry(...)) directly (bypassing Diag.fail, which is what would recurse). Have Journal.ready return _file != null && !writeFailed, so the Settings block hides itself, and stamp !! ECRITURE JOURNAL IMPOSSIBLE into the buffer for the next successful flush. journal.dart:108 should additionally Diag.log.add(...) so the exported diagnostics of a later session record that the previous one had no recorder.

How to prove it

findings/S2_persistence.md § S2-F5 — A failed journal write discards the buffered lines permanently and tells nobody: s2_journal_diag_test.dart, test "J1 DEFECT". Invert the last two expectations to expect(text, contains('ALARME Fries')) and expect(Diag.critical.value, contains('journal-write')). Red now, green after. --- findings/S5_error_handling.md § S5-F4 — Journal write failures are reported to nobody, and Journal.ready keeps saying yes: the existing test with expect(Journal.ready, isTrue) changed to isFalse and expect(Diag.log, isEmpty) changed to expect(Diag.log.map((d) => d.scope), contains('journal-write')). Red now, green after. ---

S2-F6 + S5-F3 · Journal rotation erases the entire journal when the file cannot be decoded

What is wrong

Journal rotation erases the entire journal when the file cannot be decoded

File and line

lib/journal.dart:192-203 (catch (_) { await f.writeAsString(''); }), triggered from :72 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · findings/S5_error_handling.md · agent_reports/S2_refute.md · agent_reports/S5_refute.md. Found twice, and the two refuters disagree on severity. S2's refuter reproduced the destruction (probe J3: 3,146,812 bytes → 167 bytes) and kept HIGH; lcov confirms 192,194,195,196,197,199,201 are all uncovered. S5's refuter downgraded to MEDIUM on the ground that the trigger needs the file to first exceed _maxBytes = 3 MiB. Resolved HIGH — S12's refuter reconciled journal growth at 237 KiB/day, so the 3 MiB cap is crossed in 13 days of kiosk uptime, which makes the trigger reachable inside a fortnight. S2 owns journal.dart depth; S5's row is duplicated effort, not a boundary gap.

findings/S2_persistence.md § S2-F6 — Journal rotation erases the entire journal when the file cannot be decoded: the code, verbatim — dart // journal.dart:192-203 static Future<void> _rotate(File f) async { try { final raw = await f.readAsString(); final cut = raw.length - _keepBytes; final start = raw.indexOf('\n', cut < 0 ? 0 : cut) + 1; await f.writeAsString( '[... debut du journal tronque pour rester sous 3 Mo ...]\n' '${raw.substring(start)}'); } catch (_) { await f.writeAsString(''); // <- total destruction, silently } } Recorded run proof/01_findings/S2/03_journal_diagnostics.txt: J3 DEFECT — rotation destroys the ENTIRE journal when the file cannot be decoded journal was 3146812 bytes; after init it is 167 bytes surviving content: ================================================ SESSION 2026-08-04 12:03:38.824 · Cadence v0.4.12 APPAREIL macos ================================================ 167 bytes is the new session header alone. The line seeded as SESSION 2026-08-01 — a whole service worth of evidence is gone. findings/S5_error_handling.md § S5-F3 — Journal._rotate erases the entire flight recorder when it cannot read it: verbatim, lib/journal.dart:192-203: dart static Future<void> _rotate(File f) async { try { final raw = await f.readAsString(); final cut = raw.length - _keepBytes; final start = raw.indexOf('\n', cut < 0 ? 0 : cut) + 1; await f.writeAsString( '[... debut du journal tronque pour rester sous 3 Mo ...]\n' '${raw.substring(start)}'); } catch (_) { await f.writeAsString(''); } } Forced to fire with a 3,360,001-byte journal whose tail is a dangling UTF-8 lead byte — what a force-stop mid-write leaves behind — test S5-F4 … _rotate ERASES the whole journal when the read fails (proof/01_findings/S5/01_s5_tests_baseline.txt): S5: journal was 3360001 bytes, is now 171 chars: " ================================================ SESSION 2026-08-04 1" Every historic line is gone; the 171 characters that remain are this session's own header, written after the wipe. Diag.log is empty — nothing was reported. Mutation proof (R8): removing await f.writeAsString('') from the handler (proof/01_findings/S5/08_mutationB.patch) turns that named test red, because the file still holds the original 3 MB (proof/01_findings/S5/08_mutationB_result.txt, EXIT_CODE=1) — confirming the baseline test passes only because production code emptied the file.

What to fix

findings/S2_persistence.md § S2-F6 — Journal rotation erases the entire journal when the file cannot be decoded: read bytes, not text, and never truncate to empty on error: dart final bytes = await f.readAsBytes(); final keep = bytes.length <= _keepBytes ? bytes : bytes.sublist(bytes.length - _keepBytes); await f.writeAsBytes( utf8.encode('[... debut du journal tronque ...]\n') + keep); and on catch (e) rename the file to cadence-journal.corrupt.txt and start a fresh one, so the evidence survives, plus Diag.fail('journal-rotate', e, isCritical: true). findings/S5_error_handling.md § S5-F3 — Journal._rotate erases the entire flight recorder when it cannot read it: never destroy on error. Rename the unreadable file aside and start a new one: dart } catch (e) { try { await f.rename('${f.path}.broken'); } catch (_) {} Diag.fail('journal-rotate', e); } (init already re-creates the file at journal.dart:71 when it does not exist.) If a rename is unwanted, f.readAsString(encoding: const Utf8Codec(allowMalformed: true)) removes the dominant trigger without any data loss.

How to prove it

findings/S2_persistence.md § S2-F6 — Journal rotation erases the entire journal when the file cannot be decoded: s2_journal_diag_test.dart, test "J3 DEFECT". Invert to expect(after, contains('a whole service worth of evidence')). Red now, green after. --- findings/S5_error_handling.md § S5-F3 — Journal._rotate erases the entire flight recorder when it cannot read it: the existing test S5-F4 … _rotate ERASES the whole journal when the read fails, with its assertions inverted: expect(after.contains('ligne de journal reelle'), isTrue) and expect(Diag.log.map((d) => d.scope), contains('journal-rotate')). Red now, green after. ---

S2-F7 · A dead journal is completely silent: no diagnostic, no banner, and every later event vanishes

What is wrong

A dead journal is completely silent: no diagnostic, no banner, and every later event vanishes

File and line

lib/journal.dart:108-111 and :133-134 (also :208, :233) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · proof/01_findings/S2_refute/. Probes J2/J7 reproduce.

findings/S2_persistence.md § S2-F7 — A dead journal is completely silent: no diagnostic, no banner, and every later event vanishes: recorded run proof/01_findings/S2/03_journal_diagnostics.txt: J2 DEFECT — the journal is silently DEAD when init fails journal ready=false | Diag scopes=[audio-init] | critical={audio-init} J7 — exportCopy returns null and journals nothing when the journal is down exportCopy -> null | Diag=[] An audio failure raises a banner; the failure of the diagnostic tool itself raises nothing.

What to fix

findings/S2_persistence.md § S2-F7 — A dead journal is completely silent: no diagnostic, no banner, and every later event vanishes: in the catch at 108, add Diag.fail('journal-init', e, isCritical: true); after _file = null. Diag.fail is safe to call here — its own Journal.log at diagnostics.dart:37 no-ops when _file == null. Add the same to the exportCopy catch at 233 (Diag.fail('journal-export', e) — note that scope string is already used by ui/modals.dart, so reuse it).

How to prove it

findings/S2_persistence.md § S2-F7 — A dead journal is completely silent: no diagnostic, no banner, and every later event vanishes: s2_journal_diag_test.dart, test "J2 DEFECT". Change expect(Diag.log.map((d) => d.scope).toList(), ['audio-init']) to containsAll(['journal-init', 'audio-init']) and assert expect(Diag.critical.value, contains('journal-init')). Red now, green after. ---

S2-F8 · `Diag.fail` can itself throw, and the throw escapes the `catch` block that called it

What is wrong

Diag.fail can itself throw, and the throw escapes the catch block that called it

File and line

lib/diagnostics.dart:29 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · proof/01_findings/S2_refute/. Independently reproduced (test R8a): escaped=Bad state: toString exploded, Diag.log=0, critical={}. All ten store.dart call sites cited (:38,47,56,120,126,137,142,145,176,178) verified valid.

findings/S2_persistence.md § S2-F8 — Diag.fail can itself throw, and the throw escapes the catch block that called it: the code, verbatim — dart // diagnostics.dart:28-38 static void fail(String scope, Object e, {bool isCritical = false}) { log.add(DiagEntry(DateTime.now(), scope, '$e')); // <- can throw if (log.length > _max) log.removeAt(0); if (_warned.add(scope)) debugPrint('[cadence] $scope: $e'); if (isCritical && !critical.value.contains(scope)) { critical.value = {...critical.value, scope}; } Journal.log(isCritical ? '!! PANNE CRITIQUE' : '! panne', '$scope: $e'); } Recorded run proof/01_findings/S2/03_journal_diagnostics.txt: D1 DEFECT — Diag.fail THROWS when the error object's toString() throws Diag.fail escaped with: Bad state: toString exploded; Diag.log=0 The test additionally asserts Diag.critical.value is empty and the journal file contains no PANNE line — the failure is neither contained nor recorded.

What to fix

findings/S2_persistence.md § S2-F8 — Diag.fail can itself throw, and the throw escapes the catch block that called it: stringify defensively once, at the top: dart String msg; try { msg = '$e'; } catch (_) { msg = '<unprintable ${e.runtimeType}>'; } then use msg throughout, and wrap the Journal.log call at 37 in its own try/catch so a journal fault cannot escape the choke point either.

How to prove it

findings/S2_persistence.md § S2-F8 — Diag.fail can itself throw, and the throw escapes the catch block that called it: s2_journal_diag_test.dart, test "D1 DEFECT". Invert to expect(escaped, isNull) and expect(Diag.log, hasLength(1)). Red now, green after. ---

S2-F9 · Corrupt timers plus a wrong-typed seeded flag installs the demo kitchen over a real one

What is wrong

Corrupt timers plus a wrong-typed seeded flag installs the demo kitchen over a real one

File and line

lib/engine/store.dart:300-310 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · proof/01_findings/S2_refute/. Reproduced (R9a), and a variant needing no wrong type at all was proven (R9b): JSON-corrupt cadence-timers-v1 plus an absent cadence-seeded-v1, on any post-migration tablet where store.dart:283 already removed the zones key, seeds the seven demo dishes. This finding stands on its own reachable trigger and does not fall with S2-F1.

findings/S2_persistence.md § S2-F9 — Corrupt timers plus a wrong-typed seeded flag installs the demo kitchen over a real one: recorded run proof/01_findings/S2/01_corruption_matrix.txt: DEFECT — corrupt timers + wrong-typed seeded flag => the demo seed replaces the kitchen seedIfFresh returned true; board is now [Manouche, Mozzarella sticks, Fries, Crispy, Melt cheese, Dough, Cook chicken]

What to fix

findings/S2_persistence.md § S2-F9 — Corrupt timers plus a wrong-typed seeded flag installs the demo kitchen over a real one: treat "we could not read the flag" as "not fresh". Change line 301 to dart if (prefs.containsKey(_kSeeded)) return false; // any value at all means we have run before and add a third guard: refuse to seed when Diag.critical.value contains any load- scope, i.e. when this boot already reported corrupt data.

How to prove it

findings/S2_persistence.md § S2-F9 — Corrupt timers plus a wrong-typed seeded flag installs the demo kitchen over a real one: s2_corruption_matrix_test.dart, test "DEFECT — corrupt timers + wrong-typed seeded flag". Invert to expect(seeded, isFalse) and expect(e.timers, isEmpty). Red now, green after. ---

S2R-M1 · A corrupt clones value silently destroys the run entries of every batch in flight

What is wrong

A corrupt clones value silently destroys the run entries of every batch in flight

File and line

lib/engine/store.dart:65 and lib/engine/engine.dart:76-80 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S2_refute.md §7. load decodes the three keys independently, but reconcile() couples them: losing the clones list deletes every run entry belonging to a batch, under a key that was never damaged, with no .corrupt backup taken. Reachable through the ordinary JSON path (row C1) — no wrong type needed.

agent_reports/S2_refute.md § S2R-M1 — A corrupt clones value silently destroys the run entries of every batch in flight: M1 clones key corrupt -> runs=1/4 clones=0/3 | run backup=null | banner={load-cadence-clones-v1} and the row pair in §6 (C0 runs=6/6C1 runs=3/6). After the probe calls saveRun, the persisted run map holds one entry.

What to fix

agent_reports/S2_refute.md § S2R-M1 — A corrupt clones value silently destroys the run entries of every batch in flight: when _readList(_kClones, …) reports a drop or a total failure, do not let reconcile silently harvest the orphaned run entries — preserve cadence-run-v1 to its own .corrupt sibling in the same load, and raise the existing load-cadence-clones-v1 critical with a count of the run entries about to be dropped.

How to prove it

agent_reports/S2_refute.md § S2R-M1 — A corrupt clones value silently destroys the run entries of every batch in flight: my test "M1 — a corrupt clones value silently destroys the RUN entries…". Invert expect(store.prefs.getString('cadence-run-v1.corrupt'), isNull) to isNotNull. Red now, green after.

S3-F17 · On iOS there is no audible floor at all, and nothing detects a muted device

What is wrong

On iOS there is no audible floor at all, and nothing detects a muted device

File and line

ios/Runner/AppDelegate.swift:54-59; lib/ui/home.dart:209-218 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md. The refuter read AppDelegate.swift in full and verified every line reference in the parity table. No Xcode on this machine — the iOS half was never compiled or run, by either agent. Note that S3-F17's own proposed fix raises Diag.fail('volume-device', …, isCritical: true), a scope the operator banner cannot render — see S3R-F3.

findings/S3_audio_alarms.md § S3-F17 — on iOS there is no audible floor at all, and nothing detects a muted device: ios/Runner/AppDelegate.swift:53-59, verbatim: swift switch call.method { case "getAlarmVolume": // nil = "no device alarm stream here" → Dart uses app-level scaling. // Deliberately NOT an error: nothing is broken, iOS just has no knob. result(nil) case "setAlarmVolume": result(nil) Proven on the Dart side by test S3: when the platform has NO alarm stream (getAlarmVolume returns null, the iOS answer) the level goes to app-level gain and the channel is never written to (proof/01_findings/S3/tests/s3_volume_channel_test.dart): zero setAlarmVolume calls, zero Diag entries, empty Diag.critical — a completely silent fallback.

What to fix

findings/S3_audio_alarms.md § S3-F17 — on iOS there is no audible floor at all, and nothing detects a muted device: AVAudioSession.sharedInstance().outputVolume is readable and observable on iOS. Add a getOutputVolume verb to the cadence/volume channel on the Swift side (returning AVAudioSession.sharedInstance().outputVolume as a Double), have _initSystemVolume and the ring rising edge read it, and raise Diag.fail('volume-device', 'appareil en sourdine', isCritical: true) below a threshold. That is a defect repair — the app already promises a floor — not a new feature: the banner, the string slot and the diagnostic plumbing all exist.

How to prove it

findings/S3_audio_alarms.md § S3-F17 — on iOS there is no audible floor at all, and nothing detects a muted device: a Dart test that mocks getOutputVolume0.0 and asserts Diag.critical.value contains volume-device after boot. It cannot be written today because the verb does not exist. Device confirmation: protocol D3.

S3-F4 · The TTS engine's "I do not have that language" answer is discarded, so the voice can be silently wrong for a whole service

What is wrong

The TTS engine's "I do not have that language" answer is discarded, so the voice can be silently wrong for a whole service

File and line

android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:90-94 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md §Part 2. The refuter read MainActivity.kt in full, re-derived the eight catch (_: Exception) sites by grep, opened each, and traced every one to its result.* reply and then to the Dart consumer. The "8 of 8, nobody informed" claim holds site by site.

findings/S3_audio_alarms.md § S3-F4 — the TTS engine's "I do not have that language" answer is discarded, so the voice can be silently wrong for a whole service: MainActivity.kt:90-94, verbatim: kotlin "setLanguage" -> { try { tts?.setLanguage(Locale.forLanguageTag(call.arguments as String)) } catch (_: Exception) {} result.success(null) } The discarded contract, proof/01_findings/S3/captures/android_texttospeech.txt:1454-1462 (https://developer.android.com/reference/android/speech/tts/TextToSpeech, retrieved 2026-08-04): public int setLanguage (Locale loc) ... int Code indicating the support status for the locale. See LANG_AVAILABLE, LANG_COUNTRY_AVAILABLE, LANG_COUNTRY_VAR_AVAILABLE, LANG_MISSING_DATA and LANG_NOT_SUPPORTED. Dart blindness proven by test S3: setLocale on a dead engine is a silent no-op — the operator is never told the language change did not reach the voice (proof/01_findings/S3/tests/s3_voice_test.dart), which asserts both the call list and Diag.log are empty after setLocale('fr-FR').

What to fix

findings/S3_audio_alarms.md § S3-F4 — the TTS engine's "I do not have that language" answer is discarded, so the voice can be silently wrong for a whole service: return the status: val r = tts?.setLanguage(...) ?: TextToSpeech.ERROR; result.success(r), keeping the cast guarded and answering result.error on a bad argument. In lib/audio/voice.dart:54 and :84, treat LANG_MISSING_DATA (-1) and LANG_NOT_SUPPORTED (-2) as Diag.fail('voice-locale', …, isCritical: true) — the voiceDown banner string already exists in lib/i18n.dart.

How to prove it

findings/S3_audio_alarms.md § S3-F4 — the TTS engine's "I do not have that language" answer is discarded, so the voice can be silently wrong for a whole service: extend test/voice_test.dart with a mock whose setLanguage returns -1 and assert Diag.critical.value contains voice-locale. Red today (the value is not even read), green after.

S3-F5 · The journal records an announcement as spoken before the native side has had a chance to fail, and the native `false` is thrown away

What is wrong

The journal records an announcement as spoken before the native side has had a chance to fail, and the native false is thrown away

File and line

lib/audio/voice.dart:165-168 and :134 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md. Both discarded booleans verified at voice.dart:134 and :167 by grep and by eye. S5's refuter independently strengthened the reachability: MainActivity.kt:132-134 wires UtteranceProgressListener.onError/onStop to completeSpeak(id, false)result.success(false), and speak() at :145 replies false outright when the engine is not ready — all of it discarded after voice.dart:165 has already written parole "…" to the journal.

findings/S3_audio_alarms.md § S3-F5 — the journal records an announcement as spoken before the native side has had a chance to fail, and the native "false" is thrown away: the Dart block above, verbatim from lib/audio/voice.dart:165-168. Kotlin :150-155: kotlin val r = try { t.speak(text, TextToSpeech.QUEUE_FLUSH, params, id) } catch (_: Exception) { TextToSpeech.ERROR } if (r != TextToSpeech.SUCCESS) { pendingSpeaks.remove(id) result.success(false) } Proven by tests S3: the native side answering "I did not speak" is discarded — no Diag entry, no banner, nothing distinguishes it from success and S3: setVoice answering false is discarded too (proof/01_findings/S3/tests/s3_voice_test.dart): both assert Diag.log is empty after a false answer.

What to fix

findings/S3_audio_alarms.md § S3-F5 — the journal records an announcement as spoken before the native side has had a chance to fail, and the native "false" is thrown away: capture the answer — final ok = await _ch.invokeMethod<bool>('speak', …) ?? false; — write the parole journal line only when ok is true, and Diag.fail('voice-speak', 'moteur a refuse la phrase', isCritical: true) when it is false. Same treatment at voice.dart:134 for setVoice: downgrade the journal line to "voix par defaut" when the engine refused.

How to prove it

findings/S3_audio_alarms.md § S3-F5 — the journal records an announcement as spoken before the native side has had a chance to fail, and the native "false" is thrown away: the two tests named above invert — change expect(Diag.log, isEmpty) to expect(Diag.log.map((e) => e.scope), contains('voice-speak')). They pass today with isEmpty (proof recorded) and will fail against the fix, and vice-versa.

S3-F6 · The backstop reports itself ready when `POST_NOTIFICATIONS` was refused, and never re-checks the channel afterwards

What is wrong

The backstop reports itself ready when POST_NOTIFICATIONS was refused, and never re-checks the channel afterwards

File and line

lib/alarm_backstop.dart:79-96, specifically _ready = true after a refused permission at :83-90; consumer :64 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md. Verified by reading the block.

findings/S3_audio_alarms.md § S3-F6 — the backstop reports itself ready when POST_NOTIFICATIONS was refused, and never re-checks the channel afterwards: lib/alarm_backstop.dart:83-90, verbatim: dart if (notif == false) { // without it the safety net can ring but shows nothing — say it loudly Diag.fail('backstop-notif', 'permission notifications REFUSEE', isCritical: true); } // stale alarms from a previous process die here; sync() re-creates them await _plugin.cancelAll(); _ready = true; Proven by test S3: notification permission denied is CRITICAL but the backstop still reports itself ready and keeps scheduling (proof/01_findings/S3/tests/s3_backstop_test.dart), which asserts b.ready == true and one zonedSchedule call after a denied permission. Channel immutability confirmed at the plugin level too: flutter_local_notifications-22.1.0/android/.../FlutterLocalNotificationsPlugin.java:456-475 (canCreateNotificationChannel) creates the channel only when it does not already exist, because the app never sets channelAction — so changing importance, sound or enableVibration in lib/alarm_backstop.dart:44-59 has no effect on any device where cadence-alarms already exists.

What to fix

findings/S3_audio_alarms.md § S3-F6 — the backstop reports itself ready when POST_NOTIFICATIONS was refused, and never re-checks the channel afterwards: (a) in init, set _ready = notif != false so a refused permission stops the app pretending it has a net; (b) add a re-check on every foreground transition — onForeground() already runs there (lib/ui/home.dart:189) — calling AndroidFlutterLocalNotificationsPlugin.areNotificationsEnabled() plus a getNotificationChannels() lookup for cadence-alarms, raising and clearing Diag.fail('backstop-notif', …, isCritical: true); (c) bump the channel id to cadence-alarms-v2 so the shipped importance and sound actually take effect on devices that already carry v1.

How to prove it

findings/S3_audio_alarms.md § S3-F6 — the backstop reports itself ready when POST_NOTIFICATIONS was refused, and never re-checks the channel afterwards: the test named above flips from expect(b.ready, isTrue) to expect(b.ready, isFalse); add a second test that mocks areNotificationsEnabledfalse after a healthy init and asserts Diag.critical.value contains backstop-notif after onForeground(). Both are red today.

S3-F7 · Exact-alarm revocation on Android 12/12L cancels every backstop, and nothing in the app detects it

What is wrong

Exact-alarm revocation on Android 12/12L cancels every backstop, and nothing in the app detects it

File and line

android/app/src/main/AndroidManifest.xml:13-15; lib/alarm_backstop.dart:80 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md. Permissions verified present. Two of S3's sub-line ranges drift: it cites the boot receiver at :48-56 (actual :47-58, class name at :49) and the <queries> TTS_SERVICE action at :75-77 (actual block :68-78, action at :76). The elements are the ones it names.

findings/S3_audio_alarms.md § S3-F7 — exact-alarm revocation on Android 12/12L cancels every backstop, and nothing in the app detects it: AndroidManifest.xml:13-15, verbatim: xml <uses-permission android:name="android.permission.USE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32"/> and the documented behaviour at captures/android_exact_alarms.txt:183-189 (https://developer.android.com/develop/background-work/services/alarms/schedule, retrieved 2026-08-04), including "To check whether the permission is granted to your app, call canScheduleExactAlarms() before trying to set an exact alarm." The same capture at :167 records that USE_EXACT_ALARM "Cannot be revoked by the user", which is what bounds the exposure to API 31-32. The existing degradation path is real and tested (test/backstop_test.dart:115-128), but it covers only the refused-at-schedule case.

What to fix

findings/S3_audio_alarms.md § S3-F7 — exact-alarm revocation on Android 12/12L cancels every backstop, and nothing in the app detects it: call AndroidFlutterLocalNotificationsPlugin.canScheduleExactAlarms() (exposed by the plugin) at init and on each foreground transition; when it is false, set _exactOk = false up front and raise Diag.fail('backstop-exact', …, isCritical: true) so the operator sees the degradation before a timer needs it rather than after.

How to prove it

findings/S3_audio_alarms.md § S3-F7 — exact-alarm revocation on Android 12/12L cancels every backstop, and nothing in the app detects it: a test that mocks canScheduleExactAlarmsfalse, calls init(), syncs a running timer and asserts the first zonedSchedule already carries scheduleMode == 'inexactAllowWhileIdle' and Diag.critical.value contains backstop-exact. Today the first attempt is alarmClock — asserted green in test/backstop_test.dart:98-113 — so this test is red now.

S3-F8 · The alarm level is re-imposed only on the rising edge of a ring, so the volume rocker can mute an alarm that is already ringing

What is wrong

The alarm level is re-imposed only on the rising edge of a ring, so the volume rocker can mute an alarm that is already ringing

File and line

lib/audio/alarm_volume.dart:60-67 (onRunChanged); the only other write path is setLevel :53-56 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md. Verified by reading the file in full. sane (:44-45) handles non-finite input correctly. S3's proposed onRepeat hook does not yet exist, so its stated red-then-green test is honest.

findings/S3_audio_alarms.md § S3-F8 — the alarm level is re-imposed only on the rising edge of a ring, so the rocker can mute an alarm that is already ringing: lib/audio/alarm_volume.dart:60-67, verbatim: dart void onRunChanged({required bool anyRinging}) { if (anyRinging && !_wasRinging) { Journal.log('volume', 'niveau alarme reimpose a ${(_level * 100).round()}% (debut de sonnerie)'); apply(_level); } _wasRinging = anyRinging; } The single-write behaviour is asserted by the project's own test test/volume_test.dart:103-115 (le niveau est reimpose au DEBUT de la sonnerie, une seule fois): 20 consecutive onRunChanged(anyRinging: true) calls produce exactly one write. Mutation proof that this test is real: proof/01_findings/S3/mutations/M1_volume_floor.patch (floor 0.15 → 0.0) turns test/volume_test.dart red — proof/01_findings/S3/mutations/M1_volume_floor.after.txt, EXIT_CODE=1.

What to fix

findings/S3_audio_alarms.md § S3-F8 — the alarm level is re-imposed only on the rising edge of a ring, so the rocker can mute an alarm that is already ringing: distinguish the two sources. AlarmVolume.setLevel is only ever called from the Settings slider (lib/ui/home.dart:481), so it is safe to re-assert on every repeat as well as every rising edge: add void onRepeat() => apply(_level); and call it from _HomeScreenState.onAlarmRepeat (lib/ui/home.dart:306-312) before sounds.ringtone. A slider moved mid-ring still wins, because setLevel updates _level first.

How to prove it

findings/S3_audio_alarms.md § S3-F8 — the alarm level is re-imposed only on the rising edge of a ring, so the rocker can mute an alarm that is already ringing: a new test in test/volume_test.dart: build an AlarmVolume, call onRunChanged(anyRinging: true) then onRepeat() three times, and assert four writes. It cannot compile today (onRepeat does not exist) and passes after.

S3-F9 · `lib/audio/audio.dart` had 5.26 % line coverage: every path a silent alarm takes was untested

What is wrong

lib/audio/audio.dart had 5.26 % line coverage: every path a silent alarm takes was untested

File and line

lib/audio/audio.dart (whole file, 116 lines) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md §Part 6. Reproduced to the digit. S3's 40 new tests lift audio.dart to 97.37 % (37/38), voice.dart to 96.59 %, alarm_backstop.dart to 94.00 %, alarm_volume.dart to 100 %, whole project 71.93 % (1386/1927), suite 163 pass / 0 fail, flutter analyze 0 issues — every number reproduced exactly on the refuter's copy. But see S3R-F1: three of those 40 tests do not detect the behaviour they name, so coverage is not the same as protection.

findings/S3_audio_alarms.md § S3-F9 — lib/audio/audio.dart had 5.26% line coverage: every path a silent alarm takes was untested: baseline lcov record for the file, reproduced from the pinned tree: SF:lib/audio/audio.dart DA:23,0 DA:31,0 DA:37,0 DA:38,0 DA:39,0 DA:40,0 DA:44,0 DA:45,0 DA:46,0 DA:48,0 DA:50,0 DA:52,0 DA:58,0 DA:62,0 DA:66,0 DA:69,0 DA:71,0 DA:72,0 DA:73,0 DA:74,0 DA:76,0 DA:83,1 DA:84,3 DA:86,0 DA:87,0 DA:89,0 DA:91,0 DA:92,0 DA:96,0 DA:97,0 DA:99,0 DA:103,0 DA:104,0 DA:105,0 DA:106,0 DA:107,0 DA:111,0 DA:114,0 LF:38 LH:2 end_of_record The remedy is written and measured: proof/01_findings/S3/tests/s3_audio_test.dart (14 tests) takes the file to 37/38 lines, 97.37% — proof/01_findings/S3/03_lcov_with_s3.info [not published]. The single remaining uncovered line is :107 (Vibration.vibrate(duration: 120) inside hapticStep).

What to fix

findings/S3_audio_alarms.md § S3-F9 — lib/audio/audio.dart had 5.26% line coverage: every path a silent alarm takes was untested: land proof/01_findings/S3/tests/s3_audio_test.dart as test/audio_test.dart. It needs two dev-dependencies the project does not yet declare — audioplayers_platform_interface: ^7.2.0 and vibration_platform_interface: ^0.1.2, both already in pubspec.lock as transitive packages — added to dev_dependencies in pubspec.yaml. No production code changes and no new features.

How to prove it

findings/S3_audio_alarms.md § S3-F9 — lib/audio/audio.dart had 5.26% line coverage: every path a silent alarm takes was untested: mutation proof/01_findings/S3/mutations/M3_audio_critical.patch removes isCritical: critical from the "player pool not ready" report at audio.dart:66; the test S3: ringing before init() finished is reported CRITICAL, not thrown goes red — proof/01_findings/S3/mutations/M3_audio_critical.after.txt, EXIT_CODE=1. Restored, the suite is green: proof/01_findings/S3/04_test_restored_green.txt, EXIT_CODE=0.

S3R-F1 · Three of S3's 40 new tests assert a behaviour the whole 163-test suite cannot detect

What is wrong

Three of S3's 40 new tests assert a behaviour the whole 163-test suite cannot detect

File and line

proof/01_findings/S3/tests/s3_audio_test.dart:220-235, :321-332, :237-250; subject code lib/audio/audio.dart:62, :69, :111-115, :44-54 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S3_refute.md §4. Each of the three names a specific behaviour in its title and then asserts something true whether or not that behaviour exists. Phase 4 is expected to land these files as test/audio_test.dart; landed as-is they carry three assertions that can never go red, in the one module whose failure mode is a silent alarm. One of the three is the test S3 cites as the proof of S3-F10.

agent_reports/S3_refute.md § S3R-F1 — three of S3's 40 tests assert a behaviour the whole 163-test suite cannot detect: proof/01_findings/S3_refute/mutations/A4.patch, A4b.patch, A10.patch, A11.patch with their whole-suite --reporter=json runs raw_json/A4.json, A4b.json, A10.json, A11.json. Machine summary in mutations/mutation_results.json; each record reads "failing": [], "testDone_total": 184, "clean_after_revert": true. For contrast, the same harness on the other 21 mutations produced a failing set of exactly one.

What to fix

agent_reports/S3_refute.md § S3R-F1 — three of S3's 40 tests assert a behaviour the whole 163-test suite cannot detect: assert the player identity, not the call count. AudioPlayer exposes playerId; the fake already keys volumes and _ev by it. Replace the three ringtone calls' assertion with: capture the playerId used for each of three consecutive ringtone calls and expect(ids.toSet(), hasLength(1)), then assert that same id never appears among the click ids. For (2), assert the HapticFeedback system channel received HapticFeedbackType.lightImpact via TestDefaultBinaryMessengerBinding…setMockMethodCallHandler(SystemChannels.platform, …). For (3), assert plat.volumes.keys.single is one of the two pooled player ids, which pins the fallback.

How to prove it

agent_reports/S3_refute.md § S3R-F1 — three of S3's 40 tests assert a behaviour the whole 163-test suite cannot detect: re-run A4.patch, A10.patch and A11.patch against the repaired tests; each must produce a failing set of exactly the named test. The harness is stored and takes six seconds per mutation.

S3R-F2 · The `SecurityException` justifying BLOCKER S3-F3 cannot fire at that call site, and 1 of its 3 proposed fixes is inert

What is wrong

The SecurityException justifying BLOCKER S3-F3 cannot fire at that call site, and 1 of its 3 proposed fixes is inert

File and line

android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:57; AOSP AudioService.java:4495-4504, :3745-3765, :4613-4626, :6724-6752 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S3_refute.md §4. The finding S3-F3 survives; its evidence and a third of its remedy do not. Drop fix (1), promote fix (2).

agent_reports/S3_refute.md § S3R-F2 — the SecurityException S3 uses to justify BLOCKER S3-F3 cannot fire at that call site, and 1 of its 3 proposed fixes is inert: full quoted capture with line numbers and a sha256 of the decoded source in proof/01_findings/S3_refute/captures/aosp_audioservice_setStreamVolume.txt. Key blocks quoted in Part 1 above.

What to fix

agent_reports/S3_refute.md § S3R-F2 — the SecurityException S3 uses to justify BLOCKER S3-F3 cannot fire at that call site, and 1 of its 3 proposed fixes is inert: drop fix (1) from S3-F3 and promote fix (2) to the whole remedy — have setAlarmVolume answer audio.getStreamVolume(AudioManager.STREAM_ALARM).toDouble() / max and have _applyAlarmLevel compare the achieved level against the requested one, raising a critical diagnostic on mismatch. Keep the try/catch but make it reply result.error, since it costs nothing; do not rely on it. Device protocol D2 is unchanged and is what settles it on hardware.

How to prove it

agent_reports/S3_refute.md § S3R-F2 — the SecurityException S3 uses to justify BLOCKER S3-F3 cannot fire at that call site, and 1 of its 3 proposed fixes is inert: a test in test/volume_test.dart mocking cadence/volume so setAlarmVolume answers a different level from the one requested (say 0.0 for a request of 0.4) and asserting the operator is told. That test cannot be written today because the verb returns null; it is red the moment the read-back lands and the comparison is missing, green after.

S3R-F3 · The operator banner cannot render five of the app's critical scopes, so a critical diagnostic can be raised and shown to nobody — and two of S3's own fixes land in exactly that hole

What is wrong

The operator banner cannot render five of the app's critical scopes, so a critical diagnostic can be raised and shown to nobody — and two of S3's own fixes land in exactly that hole

File and line

lib/ui/home.dart:670-703 (the _criticalBanner scope map, six startsWith prefixes, no else); live offender lib/engine/store.dart:270 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S3_refute.md §4. Overlaps and strengthens S5-F5, which found the same hole from the error-handling side and counted one scope. S3's refuter enumerated five unrenderable scopes and showed that S3-F3 fix (3) (Diag.fail('volume-set', …, isCritical: true)) and S3-F17's proposed volume-device scope both land in it. Fix the else branch before implementing either.

agent_reports/S3_refute.md § S3R-F3 — the operator banner cannot render five of the app's scopes, so a critical diagnostic can be raised and shown to nobody — and two of S3's fixes land in exactly that hole: lib/ui/home.dart:673-689, verbatim: dart final msgs = <String>{}; for (final scope in crit) { if (scope.startsWith('voice')) { msgs.add(i18n.call('voiceDown')); } else if (scope.startsWith('audio')) { msgs.add(i18n.call('audioDown')); } else if (scope.startsWith('save')) { msgs.add(i18n.call('saveFail')); } else if (scope.startsWith('load')) { msgs.add(i18n.call('loadFail')); } else if (scope.startsWith('wakelock')) { msgs.add(i18n.call('screenDown')); } else if (scope.startsWith('backstop')) { msgs.add(i18n.call('backstopDown')); } } if (msgs.isEmpty) return const SizedBox.shrink(); grep -rn "isCritical: true" lib/ returns 13 call sites; migrate-zone-sound at store.dart:270 is the one that no prefix matches. Proven by three new tests I wrote and ran, proof/01_findings/S3_refute/tests/s3r_banner_test.dart, output recorded in proof/01_findings/S3_refute/02_banner_defect_test.txt (EXIT_CODE=0, 3 passed): a controlDiag.fail('audio-play', 'boom', isCritical: true) renders ⚠️ Sound is not working on this tablet; the defectDiag.fail('migrate-zone-sound', 'boom', isCritical: true) puts the scope in Diag.critical while no widget with the banner colour 0xFFB3452B is built at all; and the fix-hole — the same is true of a critical volume-set and volume-device together.

What to fix

agent_reports/S3_refute.md § S3R-F3 — the operator banner cannot render five of the app's scopes, so a critical diagnostic can be raised and shown to nobody — and two of S3's fixes land in exactly that hole: replace the startsWith chain with a const Map<String, String> from scope prefix to i18n key, add 'volume' (reusing audioDown) and 'migrate' (reusing loadFail), and add a final else that falls back to a generic key rather than dropping the scope. Then add a test that iterates every scope literal appearing in a Diag.fail(..., isCritical: true) call in lib/ and asserts each maps to a non-empty message — that closes the class, not just the instances.

How to prove it

agent_reports/S3_refute.md § S3R-F3 — the operator banner cannot render five of the app's scopes, so a critical diagnostic can be raised and shown to nobody — and two of S3's fixes land in exactly that hole: s3r_banner_test.dart's second and third tests invert — change findsNothing to findsOneWidget for the banner container. They pass with findsNothing today (recorded) and fail after.

S4-F04 · The 150 ms heartbeat is gated behind three awaits with no timeout; if one never completes the board freezes silently

What is wrong

The 150 ms heartbeat is gated behind three awaits with no timeout; if one never completes the board freezes silently

File and line

lib/ui/home.dart:136 (await sounds.init()), :148 (await _initSystemVolume()), ticker created at :154 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md · proof/01_findings/S4/. Same code region as R5-F1 (a throw in _boot skips the ticker) and S12-F1 (unmount during the awaits orphans the ticker). Three distinct failure modes, three distinct fixes — do not collapse them. S14's refuter proved the sibling hang in main() by execution (EXIT_CODE=124 at 90 s), which establishes the class.

findings/S4_ui.md § S4-F04 — The 150 ms heartbeat is gated behind three awaits with no timeout; if one never completes the board freezes silently: grep, proof/01_findings/S4/greps.txt: ### grep -rn .timeout( lib/ lib/audio/voice.dart:168: .timeout(const Duration(seconds: 12)); exit=0 Behaviour with the audio platform channel answering with a future that never completes, proof/01_findings/S4/boot_hang.txt: BOOT[hang] heartbeatRebuildsOver20Ticks=0 bannerVisible=false criticalScopes={} tileStillShown=1 Twenty pumped 150 ms intervals produced zero rebuilds, no banner and no critical scope, while the tile stayed on screen — a board that looks alive and is not.

What to fix

findings/S4_ui.md § S4-F04 — The 150 ms heartbeat is gated behind three awaits with no timeout; if one never completes the board freezes silently: put the heartbeat first and the dependencies second. (a) Move _ticker = Timer.periodic(...) above the three awaits, or (b) keep the order but bound each await — await sounds.init().timeout(const Duration(seconds: 5), onTimeout: () => throw TimeoutException('audio init')) and likewise for _initSystemVolume() and backstop.init(), with the existing catch reporting through Diag.fail(..., isCritical: true) so the banner appears. Either way the countdown must never depend on a plugin answering. Option (b) is smaller and keeps the documented reason for awaiting audio (lib/ui/home.dart:89-91).

How to prove it

findings/S4_ui.md § S4-F04 — The 150 ms heartbeat is gated behind three awaits with no timeout; if one never completes the board freezes silently: heartbeat_starts_even_when_audio_never_answers — mock xyz.luan/audioplayers with a Completer that is never completed, pump 20 × 150 ms, and assert the home State rebuilt at least 15 times and that a critical banner is visible. Red now (0 rebuilds, no banner), green after.

S6R-F1 · An out-of-set tone name reaches `assetFor` unvalidated and rings a silent alarm

What is wrong

An out-of-set tone name reaches assetFor unvalidated and rings a silent alarm

File and line

lib/engine/store.dart:274-277 (the v0.4.11 migration), lib/engine/models.dart:71 (fromJson), lib/engine/engine.dart:66 (soundFor), lib/audio/audio.dart:83-84 (assetFor) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S6_refute.md §Findings S6 missed. S6's negative result ("Referenced-but-missing WAVs: Zero") is true of C.tones but C.tones is not the reachable input set of assetFor. migrateZoneSounds copies any string out of the legacy zones JSON onto TimerDef.sound with no membership check, and fromJson accepts any string from prefs. Interacts with S3-F19 — see CONTRADICTIONS.md §7.

agent_reports/S6_refute.md § S6R-F1 — An out-of-set tone name reaches assetFor unvalidated and rings a silent alarm: proof/01_findings/S6_refute/missing_asset_silent_alarm.txt (EXIT_CODE=0, 3 tests pass, i.e. the defect reproduces), test source at proof/01_findings/S6_refute/tests/s6r_missing_asset_test.dart. The migration test seeds a pre-v0.4.11 tablet whose zone carries 'sound': 'Sonnerie' and asserts: dart final moved = store.migrateZoneSounds(e); expect(moved, 1); final t = e.timers.single; expect(t.sound, 'Sonnerie'); // straight from legacy JSON, unchecked expect(File('assets/audio/${SoundBox.assetFor(t.sound)}').existsSync(), isFalse, reason: 'a migrated timer now rings SILENTLY'); and lib/engine/store.dart:274-277 verbatim: dart for (final t in e.timers) { final inherited = tones[t.legacyZoneId] ?? _legacyFallbackSound; if (t.sound != inherited) { t.sound = inherited;

What to fix

agent_reports/S6_refute.md § S6R-F1 — An out-of-set tone name reaches assetFor unvalidated and rings a silent alarm: clamp on the way in, in Engine.soundFor (one place, covers migration, JSON and the editor): String soundFor(TimerDef t) => C.tones.contains(t.sound) ? t.sound : kDefaultSound; — or, if engine.dart must not import lib/ui/theme.dart, move the tone list to lib/engine/models.dart beside kDefaultSound and have C.tones read it. No new feature.

How to prove it

agent_reports/S6_refute.md § S6R-F1 — An out-of-set tone name reaches assetFor unvalidated and rings a silent alarm: the second and third tests in proof/01_findings/S6_refute/tests/s6r_missing_asset_test.dart invert — soundFor returns kDefaultSound and the asset resolves to a file that exists. They pass today (proving the hole) and must fail after the fix, with the assertions flipped to isTrue.

S7-F1 · The 150 ms heartbeat is untested, and its named constant has zero consumers

What is wrong

The 150 ms heartbeat is untested, and its named constant has zero consumers

File and line

lib/engine/engine.dart:32 and lib/ui/home.dart:154 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md. The refuter reproduced grep -rn "Engine.tickMs" test/zero references, and re-applied S7's tickMs 150→5000 mutation itself: +123: All tests passed!, EXIT_CODE=0. The dead-constant half is the same defect as S6-F2; the untested-heartbeat half is S7's own and is not duplicated.

findings/S7_tests.md § S7-F1 — The 150 ms heartbeat is untested, and its named constant has zero consumers: $ grep -rn "tickMs\|milliseconds: 150" lib/ test/ lib/ui/home.dart:154: _ticker = Timer.periodic(const Duration(milliseconds: 150), (_) { lib/engine/engine.dart:32: static const int tickMs = 150; mutations/M01.txt and M02.txt both end 00:0N +123: All tests passed! / EXIT_CODE=0 (123 + 0 = 123, so every test ran). Patches M01.patch, M02.patch, both distinct.

What to fix

findings/S7_tests.md § S7-F1 — The 150 ms heartbeat is untested, and its named constant has zero consumers: make home.dart:154 read const Duration(milliseconds: Engine.tickMs) (R7 — one constant, one owner), then add the test below.

How to prove it

findings/S7_tests.md § S7-F1 — The 150 ms heartbeat is untested, and its named constant has zero consumers: in test/home_test.dart, test('the heartbeat period is the engine tick', () => expect(Engine.tickMs, 150)); plus testWidgets('the board re-renders at least 6 times per second', …) which pumps HomeScreen with the injected clock of S7-F3 and counts TileView rebuilds over one simulated second, asserting greaterThanOrEqualTo(6). Red under M02.patch, green after.

S7-F13 · The ringtone-asset test cannot detect a filename-case mismatch on macOS

What is wrong

The ringtone-asset test cannot detect a filename-case mismatch on macOS

File and line

test/i18n_defaults_test.dart:70-78; subject lib/audio/audio.dart:83-84 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md §10. Verified verbatim at source.

findings/S7_tests.md § S7-F13 — The ringtone-asset test cannot detect a filename-case mismatch on macOS: subject, verbatim lib/audio/audio.dart:83-84: dart static String assetFor(String tone) => '${tone.toLowerCase().replaceAll('buzzer', 'buzz')}.wav'; test, verbatim test/i18n_defaults_test.dart:73-77: dart for (final t in C.tones) { final f = File('assets/audio/${SoundBox.assetFor(t)}'); expect(f.existsSync(), isTrue, reason: '$t → ${f.path} is missing'); expect(f.lengthSync(), greaterThan(1000), reason: '$t is an empty file'); } filesystem proof: $ ls assets/audio/ | head -3 beep.wav bell.wav bowl.wav $ test -f assets/audio/Chirp.wav && echo resolves resolves $ diskutil info / | grep "File System Personality" File System Personality: APFS mutations/M52.txt → all 123 green, EXIT_CODE=0. Scope note: the mutation proves the test is blind to case. The consequence for the shipped app follows from Flutter resolving AssetSource('audio/…') against manifest keys generated from the on-disk names; that mechanism is not something this stream measured on device.

What to fix

findings/S7_tests.md § S7-F13 — The ringtone-asset test cannot detect a filename-case mismatch on macOS: assert the exact expected filename per tone, and compare against a real directory listing, which is case-exact on any filesystem.

How to prove it

findings/S7_tests.md § S7-F13 — The ringtone-asset test cannot detect a filename-case mismatch on macOS: dart test('assetFor produces the exact on-disk filename', () { expect(SoundBox.assetFor('Chirp'), 'chirp.wav'); expect(SoundBox.assetFor('Buzz'), 'buzz.wav'); expect(SoundBox.assetFor('Buzzer'), 'buzz.wav'); final onDisk = Directory('assets/audio').listSync().map((f) => f.uri.pathSegments.last).toSet(); for (final t in C.tones) { final name = SoundBox.assetFor(t); expect(name, name.toLowerCase(), reason: '$t → $name is not lowercase'); expect(onDisk, contains(name), reason: '$t → $name absent from the real listing'); } }); Red under M52.patch, green at HEAD.

S7-F16 + S4-F13 + S5-F9 · Six `lib/` files — 837 of 1,927 instrumented lines, 43.4 % of the codebase — are executed by zero tests

What is wrong

Six lib/ files — 837 of 1,927 instrumented lines, 43.4 % of the codebase — are executed by zero tests

File and line

lib/main.dart, lib/ui/home.dart (722), lib/ui/tile.dart (819), lib/ui/header.dart (215), lib/ui/theme.dart, lib/ui/logo.dart — all 0.00 % at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · findings/S4_ui.md · findings/S5_error_handling.md · proof/00_baseline/coverage.txt. Found three times, from the test, UI and error-handling angles. S7's refuter turned the aggregate into a measured consequence: the six 0 %-coverage files hold 43.4 % of instrumented lines and took 2 of S7's 57 mutations; line-proportional sampling would have placed ~25, and every one of those would have survived by construction. S5's refuter noted S5-F9 partly double-counts a baseline fact and survives only as maintainability.

findings/S4_ui.md § S4-F13 — 1,541 lines of interaction model are at 0.00 % coverage, and four defects were sitting in them: baseline (proof/00_baseline/SUMMARY.md §6, files at 0.00 %) plus the code map's importer proof: grep -rn "package:cadence/ui/tile.dart" test/ → no output; grep -rn "package:cadence/ui/home.dart" test/ → no output. Against that, this stream's runs: proof/01_findings/S4/layout_stress.txt, touch_targets.txt, rebuild_cost.txt. findings/S5_error_handling.md § S5-F9 — lib/ui/tile.dart: 819 lines, no error handling, no coverage, all countdown rendering: verbatim, the unguarded rendering arithmetic at lib/ui/tile.dart:184-201: dart case 'running': final stepDur = r!.chain ? t.steps![r.stepIndex].sec : t.durationSec; final rem = (r.endsAt! - widget.nowMs) / 1000.0; pieP = (rem / stepDur).clamp(0.0, 1.0); and the proof that the invariant is enforced upstream, lib/engine/engine.dart:81-91: dart run.removeWhere((id, r) { final def = _defFor(id); if (def == null) return true; if (r.status == RunStatus.running && r.endsAt == null) return true; if (r.status == RunStatus.paused && r.remainingMs == null) return true; if (r.chain) { if (!def.isChain) return true; if (r.stepIndex < 0 || r.stepIndex >= def.steps!.length) return true; } return false; }); demonstrated by test S5-F1 … the structural invariants tick() guards are already enforced by reconcile() (proof/01_findings/S5/01_s5_tests_baseline.txt), which shows both violating shapes being dropped. Coverage figure from the audit baseline (AGENT_RULES.md §Verified baseline facts: ui/tile.dart among the 0.00% files). findings/S7_tests.md § S7-F16 — Six lib/ files, 837 of 1,927 instrumented lines, are executed by zero tests: baseline proof/00_baseline/SUMMARY.md §6, verbatim rows: | 1 | lib/main.dart | 0 | 17 | 0.00% | | 2 | lib/ui/header.dart | 0 | 70 | 0.00% | | 3 | lib/ui/home.dart | 0 | 390 | 0.00% | | 4 | lib/ui/logo.dart | 0 | 4 | 0.00% | | 5 | lib/ui/theme.dart | 0 | 12 | 0.00% | | 6 | lib/ui/tile.dart | 0 | 344 | 0.00% | M02.txt, M57.txt → all 123 green, EXIT_CODE=0, distinct patches.

What to fix

findings/S4_ui.md § S4-F13 — 1,541 lines of interaction model are at 0.00 % coverage, and four defects were sitting in them: land the Phase 4 suite specified below (§"Widget tests Phase 4 must add"). The harness already exists at proof/01_findings/S4/tests/s4_harness.dart and boots a real HomeScreen with every platform channel stubbed. findings/S5_error_handling.md § S5-F9 — lib/ui/tile.dart: 819 lines, no error handling, no coverage, all countdown rendering: the ErrorWidget.builder from S5-F2 covers the visible half — replace the grey box with the app's own "this tile failed" surface and a Diag.fail('tile-render', …). Separately, add a widget test that pumps TileView with a RunEntry(status: running, chain: true) against a non-chained TimerDef and asserts the app degrades rather than throws. That test is worth more than the guard. findings/S7_tests.md § S7-F16 — Six lib/ files, 837 of 1,927 instrumented lines, are executed by zero tests: the test files specified in §4.

How to prove it

findings/S4_ui.md § S4-F13 — 1,541 lines of interaction model are at 0.00 % coverage, and four defects were sitting in them: flutter test --coverage and assert lib/ui/tile.dart and lib/ui/home.dart are each ≥ 60 % line coverage, and that lib/ui/theme.dart is ≥ 90 % (it is 82 lines of pure functions). Red now (0.00 %), green after. --- findings/S5_error_handling.md § S5-F9 — lib/ui/tile.dart: 819 lines, no error handling, no coverage, all countdown rendering: testWidgets('a tile with a violated run invariant degrades, not throws') pumping the shape above; today it throws Null check operator used on a null value from tile.dart:186, after the fix it renders a failure surface and Diag.log contains tile-render. --- findings/S7_tests.md § S7-F16 — Six lib/ files, 837 of 1,927 instrumented lines, are executed by zero tests: flutter test --coverage reports non-zero line coverage for each of the six, and M02.patch and M57.patch both go red.

S7-F2 · Every assertion about the alarm lead is computed from the constant it claims to test

What is wrong

Every assertion about the alarm lead is computed from the constant it claims to test

File and line

test/engine_test.dart:61, :145, :166, :172; test/robustness_test.dart:291 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md §10. Listed by the refuter under "what I did not overturn — verified verbatim at source". S1's refuter independently re-applied the alarmLeadMs → 0 mutation and got +123: All tests passed!.

findings/S7_tests.md § S7-F2 — Every assertion about the alarm lead is computed from the constant it claims to test: verbatim test/engine_test.dart:61-71: dart const ring = 60000 - Engine.alarmLeadMs; host.t += ring - 1; e.tick(); expect(host.fired, isEmpty); host.t += 1; e.tick(); expect(host.fired, ['a']); verbatim test/robustness_test.dart:290-291: dart expect(e.run['p']!.driftMs, inInclusiveRange(6500 + Engine.alarmLeadMs, 7500 + Engine.alarmLeadMs)); mutations/M03.txt00:02 +123: All tests passed!, EXIT_CODE=0.

What to fix

findings/S7_tests.md § S7-F2 — Every assertion about the alarm lead is computed from the constant it claims to test: pin the value once, and add one literal-instant test; keep the derived assertions as readability aids.

How to prove it

findings/S7_tests.md § S7-F2 — Every assertion about the alarm lead is computed from the constant it claims to test: test('the alarm lead is 1.2 s', () => expect(Engine.alarmLeadMs, 1200)); and: start a 60 s timer at host.t = 1000000, tick at 1058799 expecting host.fired empty, tick at 1058800 expecting ['a']. Red under M03.patch (it would fire at 1059600), green at HEAD.

S7-F3 · `lib/ui/home.dart` (722 lines) has no test, and cannot be time-tested as written

What is wrong

lib/ui/home.dart (722 lines) has no test, and cannot be time-tested as written

File and line

lib/ui/home.dart:248; file coverage 0.00 % at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · proof/00_baseline/coverage.txt. The refuter accepted the clock-injection prerequisite as real: its audit of the home_test.dart specification says the spec is implementable "after the S7-F3 clock injection, which the spec names as a prerequisite".

findings/S7_tests.md § S7-F3 — lib/ui/home.dart (722 lines) has no test, and cannot be time-tested as written: dart // lib/ui/home.dart:247-248 @override int now() => DateTime.now().millisecondsSinceEpoch; $ grep -rn "package:cadence/ui/home.dart" test/ → no output (code map §2.18) baseline SUMMARY.md §6: lib/ui/home.dart | 0 | 390 | 0.00%. M02.txt → all 123 green.

What to fix

findings/S7_tests.md § S7-F3 — lib/ui/home.dart (722 lines) has no test, and cannot be time-tested as written: add an injectable clock — const HomeScreen({required this.store, this.clock = _wallClock}) used by now(). No user-facing change (R6-safe). Then add test/home_test.dart per §4.2.

How to prove it

findings/S7_tests.md § S7-F3 — lib/ui/home.dart (722 lines) has no test, and cannot be time-tested as written: testWidgets('a restored overdue timer rings on the first tick and announces once') — seed a run entry whose endsAt is in the past, pump HomeScreen with the injected clock and a recording voice double, advance one tick, assert exactly one enqueued phrase. Today the test cannot be written at all; after the fix it is red under M02.patch and green at HEAD.

S7-F4 · The backstop's past-deadline guard is not what makes its own test pass

What is wrong

The backstop's past-deadline guard is not what makes its own test pass

File and line

lib/alarm_backstop.dart:181; test test/backstop_test.dart:88-96 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md §10. Listed under "what I did not overturn — verified verbatim at source".

findings/S7_tests.md § S7-F4 — The backstop's past-deadline guard is not what makes its own test pass: the guard, verbatim lib/alarm_backstop.dart:178-181: dart // A deadline at or behind the wall clock never gets a backstop: the // in-app engine rings it within one tick (this is the −10s-past-zero // case — Android rejects past dates and the error must not cascade). if (at <= DateTime.now().millisecondsSinceEpoch + 500) return; the assertions, verbatim test/backstop_test.dart:92-95: dart b.sync(engineWith(-5000), 'ringing'); await Future<void>.delayed(Duration.zero); expect(scheduled(), isEmpty); // no call, no error, no banner expect(Diag.critical.value, isEmpty); mutations/M44.txt00:02 +123: All tests passed!, EXIT_CODE=0; patch M44.patch.

What to fix

findings/S7_tests.md § S7-F4 — The backstop's past-deadline guard is not what makes its own test pass: assert the guard directly instead of its downstream effect.

How to prove it

findings/S7_tests.md § S7-F4 — The backstop's past-deadline guard is not what makes its own test pass: add to G1 expect(Diag.log.map((d) => d.scope), isNot(contains('backstop-past')), reason: 'the guard must return before the plugin is ever called'); Red under M44.patch (the plugin raises ArgumentError, so backstop-past is logged), green at HEAD.

S7-F5 · No backstop test uses a chained timer, so the final-deadline rule is unprotected

What is wrong

No backstop test uses a chained timer, so the final-deadline rule is unprotected

File and line

lib/alarm_backstop.dart:108-112; test/backstop_test.dart:70-79 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md §10. "Strengthened by execution counts S7 did not measure."

findings/S7_tests.md § S7-F5 — No backstop test uses a chained timer, so the final-deadline rule is unprotected: verbatim lib/alarm_backstop.dart:107-112: dart var at = r.endsAt!; if (r.chain && t.steps != null) { for (var i = r.stepIndex + 1; i < t.steps!.length; i++) { at += t.steps![i].sec * 1000; // final deadline of the whole chain } } the only engine builder in the file, verbatim test/backstop_test.dart:70-73: dart Engine engineWith(int endsAtDelta) { final e = Engine(FakeHost()); e.timers = [TimerDef(id: 'a', name: 'Fries', durationSec: 60)]; mutations/M46.txt → all 123 green, EXIT_CODE=0.

What to fix

findings/S7_tests.md § S7-F5 — No backstop test uses a chained timer, so the final-deadline rule is unprotected: add the chained case to backstop_test.dart.

How to prove it

findings/S7_tests.md § S7-F5 — No backstop test uses a chained timer, so the final-deadline rule is unprotected: dart test('a chained timer is backed by the END of the chain, not the current step', () async { final b = Backstop(); await b.init(); final e = Engine(FakeHost()); e.timers = [TimerDef(id: 'c', name: 'Chicken', durationSec: 810, steps: [ StepDef(name: 'Cook', sec: 360), StepDef(name: 'Flip', sec: 90), StepDef(name: 'Cook', sec: 360)])]; final stepEnd = DateTime.now().millisecondsSinceEpoch + 360000; e.run = {'c': RunEntry(status: RunStatus.running, chain: true, stepIndex: 0, endsAt: stepEnd)}; b.sync(e, 'ringing'); await Future<void>.delayed(Duration.zero); final iso = (scheduled().single.arguments as Map)['scheduledDateTimeISO8601'] as String; expect(DateTime.parse(iso).millisecondsSinceEpoch, stepEnd + 450000 + 1500); }); Red under M46.patch (it would schedule at stepEnd + 1500), green at HEAD.

S7-F6 · Nothing proves the backstop cancels the OS alarm of a timer that was stopped

What is wrong

Nothing proves the backstop cancels the OS alarm of a timer that was stopped

File and line

lib/alarm_backstop.dart:127-129 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md. S10's refuter independently verified the same cancel path from the product side (:127-128 cancels the armed alarm the moment a run stops matching _desired).

findings/S7_tests.md § S7-F6 — Nothing proves the backstop cancels the OS alarm of a timer that was stopped: verbatim lib/alarm_backstop.dart:125-129: dart // 1) Immediate cancel: a timer no longer running loses its net now — a // phantom ring for a stopped timer is the one thing we never risk. for (final id in _scheduled.keys.toList()) { if (!desired.containsKey(id)) _cancel(id); } mutations/M49.txt → all 123 green. grep -n "'cancel'" test/backstop_test.dart → no assertion on a cancel call anywhere in the file.

What to fix

findings/S7_tests.md § S7-F6 — Nothing proves the backstop cancels the OS alarm of a timer that was stopped: add the cancel path to backstop_test.dart.

How to prove it

findings/S7_tests.md § S7-F6 — Nothing proves the backstop cancels the OS alarm of a timer that was stopped: dart test('stopping a timer cancels its OS alarm immediately', () async { final b = Backstop(); await b.init(); final e = engineWith(60000); b.sync(e, 'ringing'); await Future<void>.delayed(Duration.zero); e.run.remove('a'); calls.clear(); b.sync(e, 'ringing'); expect(calls.where((c) => c.method == 'cancel'), hasLength(1)); }); Red under M49.patch, green at HEAD.

S7-F8 · The voice queue's pacing, staleness and cancellation guards all survive mutation

What is wrong

The voice queue's pacing, staleness and cancellation guards all survive mutation

File and line

lib/audio/voice.dart:177 (300 ms gap), :184 (20 s staleness), :174 (generation guard) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md §10. "Strengthened by execution counts S7 did not measure."

findings/S7_tests.md § S7-F8 — The voice queue's pacing, staleness and cancellation guards all survive mutation: verbatim lib/audio/voice.dart:174-185: dart if (myGen != _gen) return; // this utterance was cancelled by stopFor() _speaking = false; _currentId = null; Timer(const Duration(milliseconds: 300), _drain); } void _dropStale() { if (_queue.isEmpty) return; final now = DateTime.now().millisecondsSinceEpoch; _queue.removeWhere((x) { if (now - x.at < staleMs) return false; M38.txt, M39.txt, M40.txt each end +123: All tests passed!, EXIT_CODE=0; three distinct patches.

What to fix

findings/S7_tests.md § S7-F8 — The voice queue's pacing, staleness and cancellation guards all survive mutation: drive the queue with package:fake_async so the timings are asserted, not waited out.

How to prove it

findings/S7_tests.md § S7-F8 — The voice queue's pacing, staleness and cancellation guards all survive mutation: three tests in voice_test.darttest('a phrase older than staleMs is dropped, not spoken'): enqueue, elapse(20001 ms), complete the in-flight utterance, assert spoken never contains it; test('the next utterance waits 300 ms'): complete the first, elapse(299 ms)spoken has length 1, elapse(2 ms) → length 2; test('a cancelled utterance never re-drains'): stopFor mid-flight, assert exactly one re-drain. Red under M39.patch, M38.patch, M40.patch respectively; green at HEAD.

S7R-F4 · `lib/ui/modals.dart` (300 instrumented lines, 65 % covered) received zero mutations; five of six the refuter placed there survive

What is wrong

lib/ui/modals.dart (300 instrumented lines, 65 % covered) received zero mutations; five of six the refuter placed there survive

File and line

lib/ui/modals.dart:217, :303, :428, :436, :523, :524 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S7_refute.md §5 · proof/01_findings/S7_refute/extension_results.json. The second-largest instrumented file, the only place a cook edits a timer, reached by two widget-test files — and nothing in the audit said whether its 65 % coverage carries any assertions. It largely does not: the editor's own duration floors and clamps are unprotected, including the 5 s per-step floor and the 0..59 seconds clamp. Those clamps also duplicate Engine.saveDef (modals.dart:520-525 vs engine.dart:368) — R7 territory that S6 missed.

agent_reports/S7_refute.md § S7R-F4 — lib/ui/modals.dart (300 instrumented lines, 65 % covered) received zero mutations; five of six I placed there survive: verbatim from proof/01_findings/S7_refute/extension_results.json (patches and full json captures in proof/01_findings/S7_refute/extension/): N01 lib/ui/modals.dart SURVIVED nred=0 seen=123 editor step seconds clamp 0..59 -> 0..120 N02 lib/ui/modals.dart SURVIVED nred=0 seen=123 editor drops the 5 s per-step floor N03 lib/ui/modals.dart SURVIVED nred=0 seen=123 minute picker upper clamp 180 -> 300 N04 lib/ui/modals.dart SURVIVED nred=0 seen=123 seconds picker step 5 s -> 10 s N05 lib/ui/modals.dart KILLED nred=2 seen=123 preset chip label pads seconds to 3 digits N06 lib/ui/modals.dart SURVIVED nred=0 seen=123 new-timer default duration 180 s -> 60 s the duplicated floor, verbatim lib/ui/modals.dart:520-525: dart void _commitStep(StepDef s, {int? minVal, int? secVal}) { final m = minVal ?? s.sec ~/ 60; var ss = secVal ?? s.sec % 60; ss = ss.clamp(0, 59); s.sec = (m * 60 + ss) < 5 ? 5 : m * 60 + ss; } line_execution.json shows N01/N02/N06 land on lines the suite never executes (hits = 0) and N03/N04 on lines it executes 4 times each and still does not assert.

What to fix

agent_reports/S7_refute.md § S7R-F4 — lib/ui/modals.dart (300 instrumented lines, 65 % covered) received zero mutations; five of six I placed there survive: implement S7's §4.10 test/modals_test.dart, and add the two clamp assertions named there through the widget (_commitStep both ends, and the minute picker at 0 and 180).

How to prove it

agent_reports/S7_refute.md § S7R-F4 — lib/ui/modals.dart (300 instrumented lines, 65 % covered) received zero mutations; five of six I placed there survive: the new tests go red under extension/N01.patch, N02.patch, N03.patch and N04.patch, and green at HEAD.

S7R-F5 · `lib/ui/tile.dart` (819 lines, the largest file in the app) received zero mutations; all three the refuter placed survive

What is wrong

lib/ui/tile.dart (819 lines, the largest file in the app) received zero mutations; all three the refuter placed survive

File and line

lib/ui/tile.dart:183, :200, :498 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S7_refute.md §5 · proof/01_findings/S7_refute/extension_results.json. The three probed outputs are the idle duration, the ringing count-up and the chained step count — the single thing a cook looks at across the pass. All three can be broken outright with the suite fully green.

agent_reports/S7_refute.md § S7R-F5 — lib/ui/tile.dart (819 lines, the largest file in the app) received zero mutations; all three I placed survive: verbatim from extension_results.json: N07 lib/ui/tile.dart SURVIVED nred=0 seen=123 a ringing tile counts DOWN instead of up (no + prefix) N08 lib/ui/tile.dart SURVIVED nred=0 seen=123 an idle tile prints nothing instead of its full duration N09 lib/ui/tile.dart SURVIVED nred=0 seen=123 a chained tile no longer prints its step count the ringing count-up, verbatim lib/ui/tile.dart:199-200: dart default: // ringing timeText = fmtUp((widget.nowMs - (r!.rangAt ?? widget.nowMs)) / 1000.0); line_execution.json: all three at hits = 0. Two further probes of the same class, also surviving: N10 lib/ui/header.dart SURVIVED header colon blink threshold 500 ms -> 900 ms N11 lib/ui/header.dart SURVIVED header clock hidden at every width (breakpoint 560 -> 5600) N12 lib/main.dart SURVIVED OS text scaling no longer disabled (MediaQuery.withNoTextScaling dropped) N13 lib/audio/audio.dart SURVIVED a sound lost before init() is no longer reported as critical

What to fix

agent_reports/S7_refute.md § S7R-F5 — lib/ui/tile.dart (819 lines, the largest file in the app) received zero mutations; all three I placed survive: implement S7's §4.3 test/tile_test.dart, §4.4 test/header_test.dart and §4.8 test/main_test.dart as specified — all three are implementable as written (§6).

How to prove it

agent_reports/S7_refute.md § S7R-F5 — lib/ui/tile.dart (819 lines, the largest file in the app) received zero mutations; all three I placed survive: the new tests go red under extension/N07.patch, N08.patch, N09.patch, N10.patch, N11.patch, N12.patch and green at HEAD.

S8-F1 + S14R-M2 · The app declares no Flutter localisations, so every framework string is English whatever the app language

What is wrong

The app declares no Flutter localisations, so every framework string is English whatever the app language

File and line

lib/main.dart:43-56 — the MaterialApp passes no localizationsDelegates, no supportedLocales, no locale; flutter_localizations absent from pubspec.yaml and pubspec.lock at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S8_i18n.md · agent_reports/S8_refute.md · agent_reports/S14_refute.md §3 S14R-M2. Found twice, independently, and settled. S8 filed it at HIGH; S14's refuter found the identical defect at the identical lines from the app-entry side and graded it MEDIUM, with the mechanism spelled out: Flutter installs DefaultMaterialLocalizations, whose locale is fixed at en_US. S8's own refuter, working without knowledge of S14's, confirmed HIGH — that is the governing grade. See CONTRADICTIONS.md §6. Related but distinct: S9R-M03 (the iOS bundle declares English only).

findings/S8_i18n.md § S8-F1 — The app declares no Flutter localisations, so every framework string is English whatever the app language: proof/01_findings/S8/probe_i18n.txt, PROBE-6 and PROBE-7 — the first inspects the shipped CadenceApp, the second a replica of main.dart:43-56 minus HomeScreen: MaterialApp.locale=null MaterialApp.supportedLocales=[en_US] MaterialApp.localizationsDelegates=null MaterialApp.localeResolutionCallback=null MaterialLocalizations runtimeType=DefaultMaterialLocalizations resolved Locale=en_US pasteButtonLabel="Paste" copyButtonLabel="Copy" cutButtonLabel="Cut" selectAllButtonLabel="Select all" okButtonLabel="OK" cancelButtonLabel="Cancel" modalBarrierDismissLabel="Dismiss" and proof/01_findings/S8/no_localised_resources.txt: `agent_reports/S14_refute.md` § S14R-M2 — The only `MaterialApp` in the product declares no localisations, so every string Flutter itself supplies stays English no matter which language the cook picked: `proof/01_findings/S14_refute/s14r_l10n_test.dart`, recorded green in `proof/01_findings/S14_refute/l10n_gap.txt` (`00:00 +1: All tests passed!`, `EXIT_CODE=0`, `GIT_HEAD: 03a176e…`). It sets the persisted language to French, confirms the app's own strings do change, then reads the localisations the real widget tree resolves:dart final store = await Store.open(); expect(store.lang, 'fr', reason: 'the operator chose French'); expect(I18n('fr').call('cancel'), isNot(I18n('en').call('cancel')), ...); await tester.pumpWidget(app.CadenceApp(store: store)); final ctx = tester.element(find.byType(HomeScreen)); final ml = MaterialLocalizations.of(ctx); expect(ml, isA(), ...); expect(Localizations.localeOf(ctx), const Locale('en', 'US'), ...); expect(ml.pasteButtonLabel, 'Paste'); expect(ml.cutButtonLabel, 'Cut'); expect(ml.selectAllButtonLabel, 'Select all'); expect(ml.okButtonLabel, 'OK'); All eight assertions hold. And: $ grep -rn "localizationsDelegates|supportedLocales|GlobalMaterialLocalizations" lib/ (no output) ```

What to fix

agent_reports/S14_refute.md § S14R-M2 — The only MaterialApp in the product declares no localisations, so every string Flutter itself supplies stays English no matter which language the cook picked: add flutter_localizations (SDK dependency, no third-party code) and pass, at lib/main.dart:43-56: dart localizationsDelegates: GlobalMaterialLocalizations.delegates, supportedLocales: const [Locale('fr'), Locale('en')], locale: Locale(store.lang), store.lang is already the single source of truth for language (lib/engine/store.dart:159-163), so no new user-facing capability appears — R6-compatible.

How to prove it

agent_reports/S14_refute.md § S14R-M2 — The only MaterialApp in the product declares no localisations, so every string Flutter itself supplies stays English no matter which language the cook picked: invert the test above. expect(ml, isA<DefaultMaterialLocalizations>()) and expect(ml.pasteButtonLabel, 'Paste') must become expect(ml.pasteButtonLabel, 'Coller') with the store set to 'fr'. That assertion fails at 03a176e — proven above, the label is 'Paste' — and passes after.

S8-F2 · The generated spoken announcement is grammatically wrong in both languages, and v0.4.7 replaced correct English with it

What is wrong

The generated spoken announcement is grammatically wrong in both languages, and v0.4.7 replaced correct English with it

File and line

lib/i18n.dart:148-150; migration at lib/engine/store.dart:187-231 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S8_i18n.md · agent_reports/S8_refute.md §1. The refuter re-derived the v0.4.7 English regression against git history, not against the finding's prose. Interacts with S2-F10 (repairGeneratedPhrases deletes the operator's own sentence if it re-runs) — same migration, confirmed by S2's refuter — and with S8-R2, which shows the operator is shown the ungrammatical phrase live in the editor, not merely told it.

findings/S8_i18n.md § S8-F2 — The generated spoken announcement is grammatically wrong in both languages, and v0.4.7 replaced correct English with it: proof/01_findings/S8/probe_seed_lang.txt, PROBE-H (every row confirms wasGeneratedByUs=true, so the migration does fire on all seven): NAME | OLD_HANDWRITTEN_EN | REGENERATED_EN | SAME? | REGENERATED_FR Manouche | The manouche is ready | The manouche is ready | true | Manouche est prêt Mozzarella sticks | The mozzarella sticks are ready | The mozzarella sticks is ready | false | Mozzarella sticks est prêt Fries | The fries are ready | The fries is ready | false | Fries est prêt Crispy | The crispy is ready | The crispy is ready | true | Crispy est prêt Melt cheese | The melt cheese is ready | The melt cheese is ready | true | Melt cheese est prêt Dough | The dough is ready | The dough is ready | true | Dough est prêt Cook chicken | The chicken is ready | The cook chicken is ready | false | Cook chicken est prêt and PROBE-I for French agreement on realistic French dish names: FR "Frites" -> "Frites est prêt" (correct French: "Les frites sont prêtes") FR "Pâtes" -> "Pâtes est prêt" (correct French: "Les pâtes sont prêtes") FR "Moules" -> "Moules est prêt" (correct French: "Les moules sont prêtes") FR "Pizza" -> "Pizza est prêt" (correct French: "La pizza est prête") This is not a re-litigation of decision L9 (research/01_prior_work.md §3). L9's rule — never write our words into the operator's data — is right and is untouched by this finding. The defect is in the words themselves, generated at speak time.

What to fix

findings/S8_i18n.md § S8-F2 — The generated spoken announcement is grammatically wrong in both languages, and v0.4.7 replaced correct English with it: two changes, both inside lib/i18n.dart, no new user-facing capability. (a) Make the French form agreement-free — '$name : c\'est prêt' — which is correct for every gender and number. (b) Make the English form keep the operator's capitalisation and choose is/are, or apply the same neutral shape: '$name — ready'. Decide once; the current construction cannot be made correct without one of the two.

How to prove it

findings/S8_i18n.md § S8-F2 — The generated spoken announcement is grammatically wrong in both languages, and v0.4.7 replaced correct English with it: extend test/i18n_defaults_test.dart with a table test over ['Frites','Pâtes','Pizza','Mozzarella sticks','Fries'] asserting the generated phrase against the agreed correct string. Red today for at least Frites, Pâtes, Mozzarella sticks, Fries. ---

S8-F3 · A French first launch seeds an English board and speaks English dish names in French sentences

What is wrong

A French first launch seeds an English board and speaks English dish names in French sentences

File and line

lib/engine/store.dart:326-343 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S8_i18n.md · agent_reports/S8_refute.md §1. Confirmed by S8's refuter, and the seed block is independently verified verbatim by S10's refuter for a different purpose (store.dart:296, 326-343, including const oven = 'Cascade', fryer = 'Chirp').

findings/S8_i18n.md § S8-F3 — A French first launch seeds an English board and speaks English dish names in French sentences: proof/01_findings/S8/probe_seed_lang.txt, PROBE-G — a genuine fresh install with deviceLang: 'fr': chrome language after a FRENCH first launch = "fr" TILE_NAME | STEP_NAMES | SPOKEN_FR MANOUCHE | - | "Manouche est prêt" MOZZARELLA STICKS | - | "Mozzarella sticks est prêt" FRIES | - | "Fries est prêt" CRISPY | - | "Crispy est prêt" MELT CHEESE | - | "Melt cheese est prêt" DOUGH | - | "Dough est prêt" COOK CHICKEN | Cook/Flip/Cook | "Cook chicken est prêt"

What to fix

findings/S8_i18n.md § S8-F3 — A French first launch seeds an English board and speaks English dish names in French sentences: move the seven names and three step names into lib/i18n.dart as a per-language seed table, and have seedIfFresh read I18n(seedLangFor(deviceLang)). The seed already computes the language two lines later (:346), so the plumbing exists. This is a translation of existing content, not a new feature (R6).

How to prove it

findings/S8_i18n.md § S8-F3 — A French first launch seeds an English board and speaks English dish names in French sentences: extend test/i18n_defaults_test.dart's "seed defaults" group to run seedIfFresh(e, deviceLang: 'fr') and assert every t.name is in the French seed table. Red today (Fries is not French), green after. ---

S9-F05 · `USE_FULL_SCREEN_INTENT` is declared and used, but the app never requests it at runtime and has no degraded path if Play declines the auto-grant

What is wrong

USE_FULL_SCREEN_INTENT is declared and used, but the app never requests it at runtime and has no degraded path if Play declines the auto-grant

File and line

android/app/src/main/AndroidManifest.xml:16 and lib/alarm_backstop.dart:53 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S9_platform_config.md · agent_reports/S9_refute.md §Part 4 · store_readiness/01_google_play.md §5.2. Confirmed. The eligibility question is disputed between the Play-readiness agent and S9's refuter — both point to the same fix, so the dispute does not change what Phase 4 does. See CONTRADICTIONS.md §2.

findings/S9_platform_config.md § S9-F05 — USE_FULL_SCREEN_INTENT is declared and used, but the app never requests it at runtime and has no degraded path if Play declines the auto-grant: Verbatim, android/app/src/main/AndroidManifest.xml:16: xml <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/> Verbatim, lib/alarm_backstop.dart:44-58 — the declaration is used: dart static const AndroidNotificationDetails _channel = AndroidNotificationDetails( 'cadence-alarms', 'Timer alarms', channelDescription: 'Rings when a timer expires while the app is not on screen', importance: Importance.max, priority: Priority.max, category: AndroidNotificationCategory.alarm, fullScreenIntent: true, The complete set of permission requests the app makes, showing full-screen intent is never asked for (lib/alarm_backstop.dart:77-80): dart final android = _plugin.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); final notif = await android?.requestNotificationsPermission(); final exact = await android?.requestExactAlarmsPermission(); $ grep -rn "requestFullScreenIntentPermission" lib/ android/ (no output) Google Play policy (https://support.google.com/googleplay/android-developer/answer/9888170, retrieved 2026-08-04, captures/play_sensitive_permissions.txt:492-497), verbatim: > For apps targeting Android 14 (API target level 34) and above, USE_FULL_SCREEN_INTENT is a > special apps access permission. Apps will only be automatically granted to use the > USE_FULL_SCREEN_INTENT permission if the core functionality of their app falls under one of the > below categories that require high priority notifications: > > setting an alarm > receiving phone or video calls > > Apps that request this permission are subject to review, and those that do not meet the above > criteria will not be automatically granted this permission. In that case, apps must request > permission from the user to use USE_FULL_SCREEN_INTENT. And the declaration deadline and auto-grant cut-over (https://support.google.com/googleplay/android-developer/answer/13392821, retrieved 2026-08-04, captures/play_fgs_fsi_requirements.txt:76), verbatim: > If you use the USE_FULL_SCREEN_INTENT permission, you are required to complete the Play Console > declaration starting can 31, 2024 to indicate if your app has a permitted core functionality and > qualifies for automatic granting. Starting January 22, 2025, for apps targeting Android 14+, only > apps that have calling or alarm functionalities will have this permission enabled by default. > Otherwise, you must get user permission to use the USE_FULL_SCREEN_INTENT permission. For apps > that did not complete the declaration or have not been approved for default enabling, developers > will need to prompt users to grant permission on new installs and gracefully degrade the > experience if denied.

What to fix

findings/S9_platform_config.md § S9-F05 — USE_FULL_SCREEN_INTENT is declared and used, but the app never requests it at runtime and has no degraded path if Play declines the auto-grant: Two parts, both compliance plumbing. (1) At submission, complete the Play Console full-screen-intent declaration under Monitor and improve > App content, declaring alarm core functionality. (2) In Backstop.init, alongside the two existing requests, call android?.requestFullScreenIntentPermission() and route a false result into Diag.fail('backstop-fsi', …, isCritical: true) so the operator banner surfaces the degraded state the way backstop-notif already does at lib/alarm_backstop.dart:83-86.

How to prove it

findings/S9_platform_config.md § S9-F05 — USE_FULL_SCREEN_INTENT is declared and used, but the app never requests it at runtime and has no degraded path if Play declines the auto-grant: A unit test against a mocked AndroidFlutterLocalNotificationsPlugin asserting that Backstop.init() invokes requestFullScreenIntentPermission exactly once, and that a false return adds backstop-fsi to Diag.critical.value. Red now (the method is never called), green after. ---

S9-F06 · `MODIFY_AUDIO_SETTINGS` is declared but `ACCESS_NOTIFICATION_POLICY` is not, so the alarm-volume write throws under Do Not Disturb and the exception is swallowed

What is wrong

MODIFY_AUDIO_SETTINGS is declared but ACCESS_NOTIFICATION_POLICY is not, so the alarm-volume write throws under Do Not Disturb and the exception is swallowed

File and line

android/app/src/main/AndroidManifest.xml:8 declared, ACCESS_NOTIFICATION_POLICY absent from lines 2-17; consumed at MainActivity.kt:57 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S9_platform_config.md · agent_reports/S9_refute.md §Part 4. Confirmed by S9's refuter against https://developer.android.com/reference/android/media/AudioManager. Read alongside S3R-F2, which proves the SecurityException from setStreamVolume with flags = 0 cannot fire — the two findings name different throw paths and Phase 4 must not conflate them.

findings/S9_platform_config.md § S9-F06 — MODIFY_AUDIO_SETTINGS is declared but ACCESS_NOTIFICATION_POLICY is not, so the alarm-volume write throws under Do Not Disturb and the exception is swallowed: The complete permission block, android/app/src/main/AndroidManifest.xml:2-17 — note what is present and that ACCESS_NOTIFICATION_POLICY is not: xml <uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.USE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32"/> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> Verbatim, MainActivity.kt:57 — the write and the swallow: kotlin try { audio.setStreamVolume(AudioManager.STREAM_ALARM, target, 0) } Android reference for AudioManager.setStreamVolume (https://developer.android.com/reference/android/media/AudioManager#setStreamVolume(int,%20int,%20int), retrieved 2026-08-04, captures/android_audiomanager_setstreamvolume.txt:4651 and the Throws clause), verbatim: > From N onward, volume adjustments that would toggle Do Not Disturb are not allowed unless the app > has been granted Notification Policy Access. See > NotificationManager.isNotificationPolicyAccessGranted(). > > ThrowsSecurityException if the volume change triggers a Do Not Disturb change and the > caller is not granted notification policy access. And the permission that gates it (https://developer.android.com/reference/android/Manifest.permission#ACCESS_NOTIFICATION_POLICY, retrieved 2026-08-04, captures/android_access_notification_policy.txt:2726-2732), verbatim: > ACCESS_NOTIFICATION_POLICY — Added in API level 23 — Marker permission for applications that > wish to access notification policy. This permission is not supported on managed profiles. > Protection level: normal

What to fix

findings/S9_platform_config.md § S9-F06 — MODIFY_AUDIO_SETTINGS is declared but ACCESS_NOTIFICATION_POLICY is not, so the alarm-volume write throws under Do Not Disturb and the exception is swallowed: Declare <uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY"/> in android/app/src/main/AndroidManifest.xml, and in MainActivity.kt narrow the catch to catch (e: SecurityException) so it reports through the existing cadence/volume failure path into Diag.fail rather than vanishing. Note that the permission is a marker only: the user must still grant Do Not Disturb access via ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS, so the operator banner is what makes the gap visible.

How to prove it

findings/S9_platform_config.md § S9-F06 — MODIFY_AUDIO_SETTINGS is declared but ACCESS_NOTIFICATION_POLICY is not, so the alarm-volume write throws under Do Not Disturb and the exception is swallowed: An instrumented test that puts the device into Do Not Disturb, calls the cadence/volume setAlarmVolume method, and asserts the channel returns a failure result that reaches Diag.critical. Red now (the channel returns success and Diag.critical stays empty because the exception is swallowed), green after. Manifest-level check that runs today: grep -c ACCESS_NOTIFICATION_POLICY android/app/src/main/AndroidManifest.xml returns 0 now, 1 after. ---

M2 (S10's refuter) · A single-duration dish can only be given a duration whose seconds are a multiple of five

What is wrong

A single-duration dish can only be given a duration whose seconds are a multiple of five

File and line

lib/ui/modals.dart:436-437, against :502-503 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S10_refute.md §6. The seconds column steps (sec + 5) % 60 up and (sec + 55) % 60 down, and the six presets (theme.dart:52-54) are all multiples of five, with no text entry for a single-mode duration. 3:07 cannot be entered at all. A chained dish can: _stepRow gives every phase a free numeric field clamped only to 0-59. The two modes disagree about what a duration is. Same two lines as S6R-F4.

agent_reports/S10_refute.md § M2 — A single-duration dish can only be given a duration whose seconds are a multiple of five: dart // lib/ui/modals.dart:436-437 col('sec', sec, () => bump(() => sec = (sec + 5) % 60), () => bump(() => sec = (sec + 55) % 60)), dart // lib/ui/modals.dart:502-503 (chain mode, same dialog) _numBox((s.sec % 60).toString().padLeft(2, '0'), (v) => _commitStep(s, secVal: int.tryParse(v) ?? 0)), agent_reports/S14_refute.md § S14R-M2 — The only MaterialApp in the product declares no localisations, so every string Flutter itself supplies stays English no matter which language the cook picked: proof/01_findings/S14_refute/s14r_l10n_test.dart, recorded green in proof/01_findings/S14_refute/l10n_gap.txt (00:00 +1: All tests passed!, EXIT_CODE=0, GIT_HEAD: 03a176e…). It sets the persisted language to French, confirms the app's own strings do change, then reads the localisations the real widget tree resolves: dart final store = await Store.open(); expect(store.lang, 'fr', reason: 'the operator chose French'); expect(I18n('fr').call('cancel'), isNot(I18n('en').call('cancel')), ...); await tester.pumpWidget(app.CadenceApp(store: store)); final ctx = tester.element(find.byType(HomeScreen)); final ml = MaterialLocalizations.of(ctx); expect(ml, isA<DefaultMaterialLocalizations>(), ...); expect(Localizations.localeOf(ctx), const Locale('en', 'US'), ...); expect(ml.pasteButtonLabel, 'Paste'); expect(ml.cutButtonLabel, 'Cut'); expect(ml.selectAllButtonLabel, 'Select all'); expect(ml.okButtonLabel, 'OK'); All eight assertions hold. And: $ grep -rn "localizationsDelegates\|supportedLocales\|GlobalMaterialLocalizations" lib/ (no output) agent_reports/S2_refute.md § S2R-M2 — markCleanExit is fire-and-forget, so a clean shutdown reports itself as an OS kill: M2 cadence-journal-clean on disk when the callback returned = false M2 next boot reports "SESSION PRECEDENTE TUEE" = true

What to fix

agent_reports/S10_refute.md § M2 — A single-duration dish can only be given a duration whose seconds are a multiple of five: none inside R6 — making the seconds column free-entry is a product decision, and the ±5 step is plausibly deliberate. Report the asymmetry between single and chain mode. agent_reports/S14_refute.md § S14R-M2 — The only MaterialApp in the product declares no localisations, so every string Flutter itself supplies stays English no matter which language the cook picked: add flutter_localizations (SDK dependency, no third-party code) and pass, at lib/main.dart:43-56: dart localizationsDelegates: GlobalMaterialLocalizations.delegates, supportedLocales: const [Locale('fr'), Locale('en')], locale: Locale(store.lang), store.lang is already the single source of truth for language (lib/engine/store.dart:159-163), so no new user-facing capability appears — R6-compatible. agent_reports/S2_refute.md § S2R-M2 — markCleanExit is fire-and-forget, so a clean shutdown reports itself as an OS kill: write the clean marker first, then flush: move await _prefs?.setBool(_kClean, true); above log(…) and await _flush(); in journal.dart:186-190. A marker written and then not flushed costs one missing line; a flush done before the marker costs a false kill report. Nothing user-facing changes, so this stays inside R6.

How to prove it

agent_reports/S10_refute.md § M2 — A single-duration dish can only be given a duration whose seconds are a multiple of five: a widget test asserting the seconds readout can reach 7 from 0 — red today at any tap count. agent_reports/S14_refute.md § S14R-M2 — The only MaterialApp in the product declares no localisations, so every string Flutter itself supplies stays English no matter which language the cook picked: invert the test above. expect(ml, isA<DefaultMaterialLocalizations>()) and expect(ml.pasteButtonLabel, 'Paste') must become expect(ml.pasteButtonLabel, 'Coller') with the store set to 'fr'. That assertion fails at 03a176e — proven above, the label is 'Paste' — and passes after. agent_reports/S2_refute.md § S2R-M2 — markCleanExit is fire-and-forget, so a clean shutdown reports itself as an OS kill: my test "M2 — markCleanExit is fire-and-forget…". Invert expect(killed, isTrue) to isFalse. Red now, green after.

MISS-1 (S1's refuter) · `reconcile()` validates chain→def but never def→chain, so a chain that loses its flag rings at the end of step 1

What is wrong

reconcile() validates chain→def but never def→chain, so a chain that loses its flag rings at the end of step 1

File and line

lib/engine/engine.dart:86-89; reachability hinge at lib/engine/models.dart:133 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S1_refute.md §5. The structural invariant enforces the implication in one direction only. tick() then takes the single-timer branch at :330-331 and calls _fireAlarm at the end of step 1's duration: a twenty-one-minute dish rings after sixty seconds, with no step chime.

agent_reports/S1_refute.md § MISS-1 — reconcile() validates chain→def but never def→chain, so a chain that loses its flag rings at the end of step 1: the guard, verbatim: dart // engine.dart:86-89 if (r.chain) { if (!def.isChain) return true; if (r.stepIndex < 0 || r.stepIndex >= def.steps!.length) return true; } proof/01_findings/S1_refute/refute_probes_run.txt, probes R3a and R3b (PASS = defect confirmed), source at s1r_refute_test.dart:194-225: dart final t = chain('c', [60, 600, 600]); // 20 more minutes after step 1 e.timers = [t]; // chain: false — the def IS a chain, the run entry does not say so e.run = {'c': RunEntry(status: RunStatus.running, endsAt: h.t + 60000, armedAt: h.t)}; e.reconcile(); expect(e.run.containsKey('c'), isTrue, reason: 'engine.dart:86-89 only guards the r.chain==true direction'); h.t += 60000; e.tick(); expect(h.fired, ['c'], reason: 'full alarm after 60 s on a 21-minute dish'); expect(h.steps, isEmpty, reason: 'no step chime, no step advance'); 00:00 +5: R3. reconcile() validates chain->def but never def->chain R3a a running entry whose chain flag was lost is accepted and the dish rings at the end of STEP 1 00:00 +6: R3. reconcile() validates chain->def but never def->chain R3b the mirror direction IS guarded (chain entry, single def) R3b is the control: a chain: true entry against a single def is dropped at :87, which is what makes the omission an asymmetry rather than a deliberate policy.

What to fix

agent_reports/S1_refute.md § MISS-1 — reconcile() validates chain→def but never def→chain, so a chain that loses its flag rings at the end of step 1: one line beside the existing guard — if (!r.chain && def.isChain) return true; — dropping the entry returns the dish to idle, which is recoverable by a tap, exactly the argument the method's own comment makes at :72-73.

How to prove it

agent_reports/S1_refute.md § MISS-1 — reconcile() validates chain→def but never def→chain, so a chain that loses its flag rings at the end of step 1: probe R3a inverted — hand reconcile() a chain def with a chain: false running entry and assert e.run is empty. Red today (the entry survives), green after.

MISS-2 (S1's refuter) · `remainingMs` is clamped on every live path and on none of the restore paths

What is wrong

remainingMs is clamped on every live path and on none of the restore paths

File and line

lib/engine/engine.dart:85 (the invariant), :258 (the consumer); live clamps at :235, :247; hinge at lib/engine/models.dart:136 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S1_refute.md §5. reconcile() accepts a paused entry whenever remainingMs is non-null and never looks at its sign; resumeTimer then computes a deadline before the resume instant and the dish rings on the next beat.

agent_reports/S1_refute.md § MISS-2 — remainingMs is clamped on every live path and on none of the restore paths: proof/01_findings/S1_refute/refute_probes_run.txt, probes R4a and R4b (PASS = defect confirmed), source at s1r_refute_test.dart:228-254: dart e.timers = [single('a', 600)]; e.run = {'a': RunEntry(status: RunStatus.paused, remainingMs: -5000)}; e.reconcile(); expect(e.run.containsKey('a'), isTrue, reason: 'engine.dart:85 checks null only, never the sign'); e.resumeTimer('a'); expect(e.run['a']!.endsAt, lessThan(h.t), reason: 'resumeTimer sets a deadline 5 s in the PAST'); e.tick(); expect(h.fired, ['a']); 00:00 +7: R4. remainingMs is clamped everywhere except on restore R4a reconcile() accepts a paused entry with a NEGATIVE remainingMs 00:00 +8: R4. remainingMs is clamped everywhere except on restore R4b the live paths DO clamp — proving the omission is an asymmetry

What to fix

agent_reports/S1_refute.md § MISS-2 — remainingMs is clamped on every live path and on none of the restore paths: extend the existing invariant — if (r.status == RunStatus.paused && (r.remainingMs == null || r.remainingMs! < 0)) return true; — or clamp at the consumer, r.endsAt = n + math.max(0, r.remainingMs ?? 0) at :258. The first is consistent with how reconcile() treats every other malformed entry.

How to prove it

agent_reports/S1_refute.md § MISS-2 — remainingMs is clamped on every live path and on none of the restore paths: probe R4a inverted — assert e.run is empty after reconcile() on a paused entry with remainingMs: -5000. Red today, green after.

R-F2 (S11's refuter) · S11's asset inventory omits 33 tracked binaries

What is wrong

S11's asset inventory omits 33 tracked binaries

File and line

android/app/src/main/res/{mipmap-*,drawable-*}/*.png (15), ios/Runner/Assets.xcassets/**/*.png (18) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S11_refute.md §7. S11 states "Total binary assets in scope: 32" and its manifest lists 36 rows; the repository tracks 65 binary image/font/audio assets. The iOS omission concealed R-F1, a BLOCKER. Any Phase-4 licence file must be rebuilt from the 65, not the 32.

agent_reports/S11_refute.md § R-F2 — S11's asset inventory omits 33 tracked binaries: R28 full git ls-files inventory; R31 per-file sizes and hashes for all 33.

What to fix

agent_reports/S11_refute.md § R-F2 — S11's asset inventory omits 33 tracked binaries: extend the inventory and the coverage manifest to 65 rows. The 15 Android PNGs are AUTHOR-ORIGINAL derivatives of assets/icon/ (verified by render, R31); the 18 iOS PNGs are THIRD-PARTY per R-F1.

How to prove it

Add a regression named R-F2 (S11's refuter) that reproduces “S11's asset inventory omits 33 tracked binaries” at 03a176e72ef0075eec86b8915cbe6e93042a3b9d and asserts the opposite after the fix; run it in the whole suite under the mutation-specificity gate in §8.

R-F3 (S11's refuter) · The LICENSES draft reproduces the wrong Flutter copyright year for two of the five icons

What is wrong

The LICENSES draft reproduces the wrong Flutter copyright year for two of the five icons

File and line

findings/S11_LICENSES_draft.md:110 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S11_refute.md §7. The draft reproduces Copyright 2014 The Flutter Authors (the flutter/flutter root LICENSE, which covers the three .copy.tmpl icons). The two maskable icons come from flutter_template_images 5.0.0, whose LICENSE reads Copyright 2013. BSD-3-Clause conditions binary redistribution on reproducing "the above copyright notice" — the notice attached to the work. findings/S11_LICENSES_draft.md is NOT APPROVED: 7 corrections required before Phase 4 commits it.

agent_reports/S11_refute.md § R-F3 — The LICENSES draft reproduces the wrong Flutter copyright year for two of the five icons: R14=== flutter/flutter LICENSE at tag 3.44.8 === Copyright 2014 The Flutter Authors. All rights reserved. === flutter_template_images 5.0.0 LICENSE first line === Copyright 2013 The Flutter Authors. All rights reserved.

What to fix

agent_reports/S11_refute.md § R-F3 — The LICENSES draft reproduces the wrong Flutter copyright year for two of the five icons: reproduce both, e.g. Copyright 2013, 2014 The Flutter Authors. All rights reserved., and name the two sources.

How to prove it

Add a regression named R-F3 (S11's refuter) that reproduces “The LICENSES draft reproduces the wrong Flutter copyright year for two of the five icons” at 03a176e72ef0075eec86b8915cbe6e93042a3b9d and asserts the opposite after the fix; run it in the whole suite under the mutation-specificity gate in §8.

R5-F1 (S5's refuter) · `_boot()` is launched unawaited and unguarded, and the 150 ms heartbeat is its last statement

What is wrong

_boot() is launched unawaited and unguarded, and the 150 ms heartbeat is its last statement

File and line

lib/ui/home.dart:86 (the call), lib/ui/home.dart:154 (the ticker) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S5_refute.md §4. initState fires _boot(); with no await, no .catchError and no enclosing try. _boot is a 60-line async method with four awaits, and the Timer.periodic that drives every countdown, alarm and repeat is created by its last statement. Any throw earlier in _boot skips the ticker permanently: the board renders, the tiles show their stored state, and nothing counts down. Same code region as S4-F04 (hang) and S12-F1 (unmount) — three failure modes, three fixes.

agent_reports/S5_refute.md § R5-F1 — _boot() is launched unawaited and unguarded, and the 150 ms heartbeat is its last statement: proof/01_findings/S5_refute/R20_ticker_lifecycle.txtgrep -n '_ticker\|_boot()' lib/ui/home.dart gives the complete lifecycle in five lines: 39: Timer? _ticker; 86: _boot(); 92: Future<void> _boot() async { 154: _ticker = Timer.periodic(const Duration(milliseconds: 150), (_) { 238: _ticker?.cancel(); Exactly one assignment, at :154; :238 is dispose. And verbatim, lib/ui/home.dart:70-87: dart @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); engine = Engine(this); widget.store.load(engine); ... alarmVol = AlarmVolume(_applyAlarmLevel, stored: widget.store.vol); _boot(); } The unguarded throw sites inside _boot are home.dart:126 (r.endsAt!) and :127 (t.steps!.length), in the restored-run logging loop at :121-134. I verified everything else in _boot is contained: sounds.init() at :136 is inside a try; backstop.init() (:149) is wrapped end to end by its own try at alarm_backstop.dart:70-96; backstop.sync (:152) reaches _desired, which guards r.endsAt == null at alarm_backstop.dart:104 and t.steps != null at :108; _initSystemVolume (:148) catches at home.dart:227 and its alarmVol.assertLevel() routes to the guarded .catchError at :211.

What to fix

agent_reports/S5_refute.md § R5-F1 — _boot() is launched unawaited and unguarded, and the 150 ms heartbeat is its last statement: two changes, both defect repair (R6). Move the ticker creation to the top of _boot, before any await, so the heartbeat cannot be lost to a later failure; and wrap the call as _boot().catchError((e, st) => Diag.fail('boot', e, isCritical: true)); so a boot failure reaches the banner via the existing map (boot needs the else catch-all from S5-F5's fix).

How to prove it

agent_reports/S5_refute.md § R5-F1 — _boot() is launched unawaited and unguarded, and the 150 ms heartbeat is its last statement: a testWidgets that pumps HomeScreen with a Store whose loaded run map contains a RunEntry(status: running, endsAt: null) injected after reconcile, then advances the clock 500 ms and asserts the displayed countdown changed. Red now (no ticker), green after.

S1-F10 · The 1,200 ms alarm lead can be changed to 0 and the whole suite stays green

What is wrong

The 1,200 ms alarm lead can be changed to 0 and the whole suite stays green

File and line

lib/engine/engine.dart:50, documented at :34-49 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S1_engine.md · proof/01_findings/S1_refute/remut_MUT6_alarmlead_zero.txt. Mutation re-applied independently: +123: All tests passed!, EXIT_CODE=0. Five test sites reference alarmLeadMs, all of the form … - Engine.alarmLeadMs — the expectations move with the code.

findings/S1_engine.md § S1-F10 — The 1 200 ms alarm lead can be changed to 0 and the whole suite stays green: proof/01_findings/S1/mutations/MUT-6_alarmlead_zero.patch sets the constant to 0: diff - static const int alarmLeadMs = 1200; + static const int alarmLeadMs = 0; MUT-6_alarmlead_zero.txt: 00:03 +123: All tests passed! EXIT_CODE=0 Three further mutations behave the same way — MUT-2 (the c.batchNo < 2 boundary in reconcile()), MUT-3 (deleting r.armedAt = null from _fireAlarm), MUT-4 (tickMs 150 → 5000) and MUT-5 (deleting the host.persistRun() inside tick()'s catch) all leave the suite at +123: All tests passed!. MUT-1 is the positive control: it does go red, so the suite is real where it does assert.

What to fix

findings/S1_engine.md § S1-F10 — The 1 200 ms alarm lead can be changed to 0 and the whole suite stays green: pin the constants against literals rather than against themselves: expect(Engine.alarmLeadMs, 1200), expect(Engine.tickMs, 150), plus one golden-instant test that starts a 60 s timer at a fixed epoch and asserts the alarm lands at exactly start + 58800, written with no reference to Engine.alarmLeadMs.

How to prove it

findings/S1_engine.md § S1-F10 — The 1 200 ms alarm lead can be changed to 0 and the whole suite stays green: s1_defects_test.dart::S1-F10 pins alarmLeadMs == 1200, tickMs == 150 and the golden ring instant start + 58800 written without reference to either constant; it passes today and is the assertion the suite is missing. The proof it closes the gap is to re-apply mutations/MUT-6_alarmlead_zero.patch and MUT-4_tickms_constant.patch and require a non-zero exit. Today both exit 0; those recorded outputs are the before-state. ---

S1-F2 + S5-F1 · A throwing host callback makes `tick()` delete the timer it has just set to ringing, and tells nobody

What is wrong

A throwing host callback makes tick() delete the timer it has just set to ringing, and tells nobody

File and line

lib/engine/engine.dart:338-343, try at :305, reached from :286 and :328 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S1_engine.md · findings/S5_error_handling.md · agent_reports/S1_refute.md · agent_reports/S5_refute.md. Found twice, downgraded twice, on the same ground. Both refuters enumerated every synchronous statement in the four host callbacks reachable from tick() (home.dart:252-258, :284-303, :306-312, :315-334) and proved none can throw synchronously with today's host: Journal.log is guarded, Store.saveRun_write is all-catching, backstop.sync and the sound calls are async. Defence-in-depth, not service behaviour. Two implementation warnings: (a) test/robustness_test.dart:149 is a pre-existing repo test that enshrines this swallow-and-drop as desired behaviour — any fix must deal with it; (b) S5's proposed EngineHost.onEngineFault needs a concrete empty default body, because an abstract member breaks every implementer in lib/ and in test/.

findings/S1_engine.md § S1-F2 — A throwing host callback makes tick() delete the timer it has just set to ringing: the code path, verbatim: dart // engine.dart:338-343 } catch (_) { // Backstop to reconcile(): one bad entry must never silence the // timers after it in the loop — drop it, idle is recoverable. run.remove(t.id); host.persistRun(); } proof/01_findings/S1/facts_run.txt, probes B1 and B3 (PASS = defect confirmed): dart // s1_facts_test.dart, probe B1 — the host throws exactly as a platform channel would final th = ThrowingHost('onAlarmFire'); // onAlarmFire -> throw StateError('audio channel down') en.startTimer(t); th.t += 60000; en.tick(); expect(th.fired, ['a']); // the callback WAS entered expect(en.run['a'], isNull, reason: 'the engine deleted the run entry it had just set to ringing'); for (var i = 0; i < 100; i++) { th.t += Engine.tickMs; en.tick(); } expect(th.repeated, isEmpty); // and it never nags again 00:00 +4: B. host exception inside tick() B1 onAlarmFire throws -> the ringing timer is DELETED 00:00 +6: B. host exception inside tick() B3 onStepAdvance throws -> the whole chain run is DELETED at step 1 B3 uses a [60, 600, 600] chain: a throw at the first boundary deletes twenty minutes of remaining cooking, and 10 000 subsequent ticks produce no alarm. Reachability from today's host is not demonstrated and I will not claim it: sounds.ringtone, backstop.showNow and voice.enqueue are all async, and an async function cannot throw synchronously into tick(). The defect is that the engine's recovery rule is written against the wrong hypothesis — it treats "the world outside me failed" as "my data is corrupt" — and converts a transient failure into permanent silent loss. The exact artefact that would raise this to BLOCKER is one synchronous throw site in any of EngineHost's four notification callbacks as implemented by _HomeScreenState; that host is stream S3/S4's scope. findings/S5_error_handling.md § S5-F1 — Engine.tick's catch deletes a live timer and tells nobody: verbatim, lib/engine/engine.dart:338-343: dart } catch (_) { // Backstop to reconcile(): one bad entry must never silence the // timers after it in the loop — drop it, idle is recoverable. run.remove(t.id); host.persistRun(); } Forced to fire, with a host that throws from onAlarmFire (proof/01_findings/S5/01_s5_tests_baseline.txt, test S5-F1 … a throw from onAlarmFire DELETES the ringing timer, silently): expect(host.fired, ['a']); // the alarm DID start firing expect(e.run.containsKey('a'), isFalse); // the run entry is gone expect(Diag.log, isEmpty); // nobody learns expect(Diag.critical.value, isEmpty); // no banner // and the next tick never fires it again All assertions pass. Mutation proof (R8): replacing the handler body with rethrow (proof/01_findings/S5/07_mutationA.patch) turns that named test red with Bad state: … blew up inside onAlarmFire at engine.dart:331 Engine.tick (proof/01_findings/S5/07_mutationA_result.txt, EXIT_CODE=1) — the test genuinely exercises this catch. The comment's stated justification is also stale: the structural invariants it claims to backstop are already enforced upstream by Engine.reconcile (engine.dart:81-91), proven in the third test of the same group (running with endsAt == null and a chain-run on a non-chain def are both dropped by reconcile, so tick never sees them).

What to fix

findings/S1_engine.md § S1-F2 — A throwing host callback makes tick() delete the timer it has just set to ringing: move the two host notifications out of the guarded region. Compute the state transition and persist inside the try; collect (TimerDef, callbackKind) pairs into a local list; dispatch them after the loop, each in its own try/catch that reports through the caller rather than mutating run. State corruption keeps deleting the entry; a host failure never does. findings/S5_error_handling.md § S5-F1 — Engine.tick's catch deletes a live timer and tells nobody: report before mutating. Add a nullable failure callback to EngineHost (the engine must stay Flutter-free, which is why it cannot call Diag itself — see the comment at engine.dart:352-354), e.g. void onEngineFault(String id, Object e), invoke it from the handler, and have _HomeScreenState implement it as Diag.fail('engine-tick', '$id: $e', isCritical: true). Keep the run.remove — dropping the entry is the right recovery — but never do it silently. Narrow the catch to catch (e, st) so the object is available.

How to prove it

findings/S1_engine.md § S1-F2 — A throwing host callback makes tick() delete the timer it has just set to ringing: s1_defects_test.dart::S1-F2 and S1-F2b — currently red. After the fix the ringing entry must survive a throwing onAlarmFire and still repeat at firstVoiceGapMs, and the [60, 600] chain must still fire its final alarm after a throwing onStepAdvance. --- findings/S5_error_handling.md § S5-F1 — Engine.tick's catch deletes a live timer and tells nobody: the test S5-F1 … a throw from onAlarmFire DELETES the ringing timer, silently (proof/01_findings/S5/s5_error_handling_test.dart) with expect(Diag.log, isEmpty) inverted to expect(Diag.log.map((d) => d.scope), contains('engine-tick')). Red now, green after.

S1-F3 · `tick()`'s own recovery path can throw, abandoning every timer after the failing one

What is wrong

tick()'s own recovery path can throw, abandoning every timer after the failing one

File and line

lib/engine/engine.dart:342 (the unguarded re-invocation); in-try calls at :285, :327 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S1_engine.md · agent_reports/S1_refute.md. Probe B2 reproduces and B2b measures the blast radius. Same reachability ceiling as S1-F2: persistRun has three synchronous leaves and all three are internally guarded or async. Latent robustness defect.

findings/S1_engine.md § S1-F3 — tick()'s own recovery path can throw, abandoning every timer after the failing one: proof/01_findings/S1/facts_run.txt, probe B2 (PASS = defect confirmed): dart // s1_facts_test.dart, probe B2 final th = ThrowingHost('persistRun'); en.timers = [t, single('later', 60)]; // engine.dart:342 — the catch(_) handler calls host.persistRun() again, // so a throwing persistRun escapes tick() and abandons the whole loop. expect(() => en.tick(), throwsA(isA<StateError>())); expect(th.fired, isEmpty, reason: 'onAlarmFire was never reached'); expect(en.run.containsKey('later'), isTrue, reason: 'the timer AFTER the failing one was never even examined'); The first, uncorrected version of this probe produced the stack trace naming the line directly: Bad state: backstop.sync threw test/s1_facts_test.dart 56:34 ThrowingHost.persistRun package:cadence/engine/engine.dart 342:14 Engine.tick Probe B2b measures the blast radius rather than assuming it: a Timer.periodic whose callback throws keeps firing, and every beat raises a separate uncaught zone error (beats > 3, errs.length == beats). So the heartbeat survives, but the board loses a timer on each beat and floods the error zone.

What to fix

findings/S1_engine.md § S1-F3 — tick()'s own recovery path can throw, abandoning every timer after the failing one: wrap the recovery's persist: try { host.persistRun(); } catch (_) {} — or, better, set a _dirty flag inside the loop and issue exactly one guarded host.persistRun() after the loop ends, which also removes the repeated writes the current code performs when several entries fire on the same beat.

How to prove it

findings/S1_engine.md § S1-F3 — tick()'s own recovery path can throw, abandoning every timer after the failing one: s1_defects_test.dart::S1-F3 — currently red. It asserts en.tick() does not throw and that both 'a' and 'later' reach onAlarmFire on the same beat despite persistRun throwing every time. ---

S1-F4 · `saveDef` silently destroys the duration of a chain reduced to one step, and the suite locks the loss in

What is wrong

saveDef silently destroys the duration of a chain reduced to one step, and the suite locks the loss in

File and line

lib/engine/engine.dart:366-374 (S1 cited :370-374); UI gate lib/ui/modals.dart:505 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S1_engine.md · agent_reports/S1_refute.md. Probe reproduces durationSec == 5 from a 5,400 s step. Mutation MUT-1 re-applied independently: the suite goes red with exactly one failing test, test/robustness_test.dart "saveDef floors (audit F7)…" — so the suite really does enforce the loss and that test must change with the fix.

findings/S1_engine.md § S1-F4 — saveDef silently destroys the duration of a chain reduced to one step, and the suite locks the loss in: proof/01_findings/S1/facts_run.txt, probes I1 and I2 (PASS = defect confirmed): dart e.saveDef(name: 'Confit', phrase: '', steps: [StepDef(name: 'Cook', sec: 5400)]); expect(e.timers.single.durationSec, 5, reason: '90 minutes became 5 SECONDS'); The existing suite actively enforces the loss. proof/01_findings/S1/mutations/MUT-1_savedef_keeps_single_step.patch changes :370 to if (steps.length < 2) { durationSec = steps.first.sec; steps = null; } — the correct behaviour — and MUT-1_savedef_keeps_single_step.txt shows the stock suite goes red on it: Expected: <5> Actual: <30> test/robustness_test.dart 311:7 main.<fn>.<fn> Failing tests: test/robustness_test.dart: saveDef floors (audit F7) engine enforces its own floors whatever the caller sends EXIT_CODE=1 Today's editor cannot produce a one-step list: lib/ui/modals.dart:505 disables the step-delete control at steps.length <= 2. The reachable path is a stored definition carrying a one-element steps array, which TimerDef.fromJson (models.dart:73-77) accepts without any length check, loaded into the editor and re-saved.

What to fix

findings/S1_engine.md § S1-F4 — saveDef silently destroys the duration of a chain reduced to one step, and the suite locks the loss in: carry the value across the degradation — if (steps.length < 2) { durationSec = steps.first.sec; steps = null; } — so the 5 s floor below it only ever applies to a caller who supplied nothing at all. Then correct test/robustness_test.dart:309-311 to expect 30.

How to prove it

findings/S1_engine.md § S1-F4 — saveDef silently destroys the duration of a chain reduced to one step, and the suite locks the loss in: s1_defects_test.dart::S1-F4 — currently red, expects 5400. The companion mutation output above is the proof that robustness_test.dart:311 must change with it. ---

S1-F5 · A retired lot number is handed out a second time, contradicting the model's own contract

What is wrong

A retired lot number is handed out a second time, contradicting the model's own contract

File and line

lib/engine/engine.dart:156-159; contract at lib/engine/models.dart:147-149 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S1_engine.md · agent_reports/S1_refute.md. Probe spawns and stops four times and measures ['p [lot 2]','p [lot 2]','p [lot 2]','p [lot 2]'].

findings/S1_engine.md § S1-F5 — A retired lot number is handed out a second time, contradicting the model's own contract: proof/01_findings/S1/facts_run.txt, probe D1 (PASS = defect confirmed) and defects_run.txt::S1-F5 (FAIL): dart final c2 = e.spawnClone('p')!; final c3 = e.spawnClone('p')!; e.stopTimer(c2); e.stopTimer(c3); final again = e.spawnClone('p')!; expect(e.labelFor(again), 'p [lot 2]', reason: 'lot 2 is handed out a SECOND time in the same service'); S1-F5 a lot number must never be handed out twice [E] Expected: true Actual: <false> lot 2 was already used this service The narrower case the existing suite does cover — stopping lot 2 while lot 3 still runs — is correctly handled (test/robustness_test.dart:224-240). The uncovered case is stopping all of them.

What to fix

findings/S1_engine.md § S1-F5 — A retired lot number is handed out a second time, contradicting the model's own contract: keep the high-water mark per parent instead of deriving it. A Map<String, int> _highBatch updated in spawnClone and rebuilt in reconcile() from the maximum of the restored clones is enough; it must be persisted alongside clones so a relaunch does not reset it.

How to prove it

findings/S1_engine.md § S1-F5 — A retired lot number is handed out a second time, contradicting the model's own contract: s1_defects_test.dart::S1-F5 — currently red. It spawns and stops a clone four times in a row and asserts every issued number is distinct. ---

S1-F6 · `reconcile()` repairs the persisted state and writes none of the repairs back

What is wrong

reconcile() repairs the persisted state and writes none of the repairs back

File and line

lib/engine/engine.dart:74-100 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S1_engine.md · agent_reports/S1_refute.md. Read in full: no persistDefs/persistRun/persistClones anywhere in the method. Probe measures all three counters at 0 after a reconcile() that dropped a zombie run entry and renumbered a clone.

findings/S1_engine.md § S1-F6 — reconcile() repairs the persisted state and writes none of the repairs back: proof/01_findings/S1/facts_run.txt, probe D4 (PASS = defect confirmed): dart final before = (h.persistRuns, h.persistClones_, h.persistDefs_); e.reconcile(); expect(e.run.keys.toList(), ['x']); // 'zombie' and 'ghost' were dropped expect(e.clones.single.batchNo, 2); // and 'x' was renumbered expect((h.persistRuns, h.persistClones_, h.persistDefs_), before, reason: 'the repaired state is never written back to storage'); and defects_run.txt::S1-F6 (FAIL): Expected: a value greater than <0> Actual: <0>.

What to fix

findings/S1_engine.md § S1-F6 — reconcile() repairs the persisted state and writes none of the repairs back: track whether anything changed inside reconcile() and, when it did, call host.persistRun() and host.persistClones() once at the end.

How to prove it

findings/S1_engine.md § S1-F6 — reconcile() repairs the persisted state and writes none of the repairs back: s1_defects_test.dart::S1-F6 — currently red. It asserts both counters are non-zero after a reconcile() that demonstrably mutated run and clones. ---

S1-F7 · `TimerDef.fromJson` throws on a bad `id`, and every `??` fallback next to it is null-only

What is wrong

TimerDef.fromJson throws on a bad id, and every ?? fallback next to it is null-only

File and line

lib/engine/models.dart:66, and :22, :67, :68, :71, :72, :75, :134, :139, :156-158 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S1_engine.md · agent_reports/S1_refute.md. Ten wrong-type inputs all throw, reproduced. models.dart:66 and CloneRef's :156-157 are the only unguarded casts; RunEntry.fromJson's status (:131-132) is the sole read that silently defaults instead of throwing.

findings/S1_engine.md § S1-F7 — TimerDef.fromJson throws on a bad id, and every ?? fallback next to it is null-only: proof/01_findings/S1/facts_run.txt, probes G1-G3 (PASS = every listed input throws): dart expect(() => TimerDef.fromJson({'id': 'a', 'name': 42}), throwsA(isA<TypeError>())); expect(() => TimerDef.fromJson({'id': 'a', 'durationSec': 60.0}), throwsA(isA<TypeError>())); expect(() => TimerDef.fromJson({'id': 'a', 'sound': 3}), throwsA(isA<TypeError>())); expect(() => TimerDef.fromJson({'id': 'a', 'phrase': true}), throwsA(isA<TypeError>())); expect(() => TimerDef.fromJson({'id': 'a', 'steps': 'nope'}), throwsA(isA<TypeError>())); expect(() => StepDef.fromJson({'name': 'A', 'sec': 30.0}), throwsA(isA<TypeError>())); expect(() => RunEntry.fromJson({'status': 'running', 'endsAt': 1.0e12}), throwsA(isA<TypeError>())); expect(() => RunEntry.fromJson({'status': 'running', 'stepIndex': 1.0}), throwsA(isA<TypeError>())); expect(() => CloneRef.fromJson({'id': 'a'}), throwsA(isA<TypeError>())); expect(() => CloneRef.fromJson({'id': 'a','parentId':'p','batchNo': 2.0}), throwsA(isA<TypeError>())); and defects_run.txt::S1-F7 / S1-F7b (FAIL), with the exact runtime messages: S1-F7 TimerDef.fromJson must not throw on a bad id [E] type 'Null' is not a subtype of type 'String' in type cast package:cadence/engine/models.dart 66:21 new TimerDef.fromJson S1-F7b the fromJson fallbacks must survive a wrong type [E] type 'double' is not a subtype of type 'int' in type cast package:cadence/engine/models.dart 68:46 new TimerDef.fromJson Consequence, engine-side: the throw is what makes a whole timer disappear from the board. The missing-id case is the one test/robustness_test.dart:41-58 already exercises through the store's per-entry salvage, and the salvage's correct behaviour is to drop the record — so a single malformed field silently costs the operator a whole dish. The remaining nine wrong-type inputs have no test at all. A separate observation from probe G4: an unrecognised status string is not treated the same way — RunEntry.fromJson (:131-132) silently coerces it to RunStatus.running. The file is inconsistent about whether unreadable data throws or is defaulted.

What to fix

findings/S1_engine.md § S1-F7 — TimerDef.fromJson throws on a bad id, and every ?? fallback next to it is null-only: make every read total. id: (j['id'] is String && (j['id'] as String).isNotEmpty) ? j['id'] as String : _synthId(), and replace each (j['x'] ?? d) as T with a typed helper — _str(j['x'], d), _int(j['x'], d) — that returns the default whenever the value is not of the expected type, and accepts an integral double for the int reads. Nine call sites, one helper pair, no behaviour change on well-formed data.

How to prove it

findings/S1_engine.md § S1-F7 — TimerDef.fromJson throws on a bad id, and every ?? fallback next to it is null-only: s1_defects_test.dart::S1-F7 and S1-F7b — currently red. They require a missing id, an int id, a double durationSec, an int name, a double step sec and a double endsAt all to round-trip to sane values rather than throw. ---

S1-F8 · Nothing bounds a duration: `durationSec * 1000` overflows into a deadline in the past

What is wrong

Nothing bounds a duration: durationSec * 1000 overflows into a deadline in the past

File and line

lib/engine/engine.dart:177, :182, :232, :372-374, :386, :396 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S1_engine.md · agent_reports/S1_refute.md. Reproduces endsAt < now and an immediate ring for durationSec = 9223372036854776. The reachable arm is the one S1 names: models.dart:68 defaults a missing durationSec to 0, and 0/negative both ring on the first tick.

findings/S1_engine.md § S1-F8 — Nothing bounds a duration: durationSec * 1000 overflows into a deadline in the past: proof/01_findings/S1/defects_run.txt::S1-F8 (FAIL), with the wrapped value verbatim: S1-F8 a duration must never produce a deadline in the past [E] Expected: a value greater than or equal to <1000000000000> Actual: <-9223371036854775616> a has a deadline before it was started and facts_run.txt, probes F1-F4 (PASS): a durationSec of 9223372036854776 rings instantly (F1); adjustTimer('a', 9223372036854775) moves endsAt backwards (F2); durationSec of 0 and -600 both ring on the first tick (F3); saveDef stores 9223372036854775 unchanged (F4).

What to fix

findings/S1_engine.md § S1-F8 — Nothing bounds a duration: durationSec * 1000 overflows into a deadline in the past: clamp once, at the single place where a duration becomes a deadline. Add static const int maxDurationSec = 24 * 3600; and apply durationSec.clamp(5, maxDurationSec) in startTimer (both branches), in each step's sec, and in saveDef's existing floor block, which then becomes a two-sided clamp. adjustTimer clamps its result to [host.now(), host.now() + maxDurationSec * 1000].

How to prove it

findings/S1_engine.md § S1-F8 — Nothing bounds a duration: durationSec * 1000 overflows into a deadline in the past: s1_defects_test.dart::S1-F8 — currently red. It starts three timers with durationSec of 9223372036854776, -600 and 0 and requires every resulting endsAt to be at or after the instant the timer was started. ---

S1-F9 · A ringing entry with no `nextVoiceAt` passes `reconcile()` and is then silent forever

What is wrong

A ringing entry with no nextVoiceAt passes reconcile() and is then silent forever

File and line

lib/engine/engine.dart:81-91 (the invariant block) and :333-335 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S1_engine.md · agent_reports/S1_refute.md. :84 checks runningendsAt, :85 checks pausedremainingMs, and nothing checks ringing. Probe runs 1,000 beats and measures zero repeats.

findings/S1_engine.md § S1-F9 — A ringing entry with no nextVoiceAt passes reconcile() and is then silent forever: proof/01_findings/S1/restart_run.txt, probe K5 (PASS = defect confirmed): dart e.run = {'a': RunEntry(status: RunStatus.ringing, rangAt: 1)}; e.reconcile(); expect(e.run.containsKey('a'), isTrue, reason: 'reconcile lets it through'); for (var i = 0; i < 1000; i++) { h.t += Engine.tickMs; e.tick(); } expect(h.repeated, isEmpty, reason: 'it shows as ringing and makes no sound'); The mirror case is handled correctly and is also proven: probe K4 shows a ringing entry with a null endsAt is legitimately kept, and K1 shows a normally-persisted ringing entry resumes nagging on relaunch. Probe K6 shows a below-floor persisted voiceGap self-heals to minVoiceGapMs on the first repeat. The gap is specifically the missing nextVoiceAt invariant.

What to fix

findings/S1_engine.md § S1-F9 — A ringing entry with no nextVoiceAt passes reconcile() and is then silent forever: one line in the reconcile() invariant block: if (r.status == RunStatus.ringing && (r.rangAt == null || r.nextVoiceAt == null)) return true; — dropping the entry returns the timer to idle, which is recoverable by a tap, exactly as the function's own comment argues at :72-73.

How to prove it

findings/S1_engine.md § S1-F9 — A ringing entry with no nextVoiceAt passes reconcile() and is then silent forever: s1_defects_test.dart::S1-F9 — currently red. It hands reconcile() a RunEntry(status: ringing, rangAt: 1) with no nextVoiceAt and asserts e.run is empty. ---

S10-F10 · The batch cap is silent: the button just disappears

What is wrong

The batch cap is silent: the button just disappears

File and line

lib/engine/engine.dart:31, :194; lib/ui/home.dart:624 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · agent_reports/S10_refute.md. —

findings/S10_product.md § S10-F10 — The batch cap is silent: the button just disappears: dart // lib/engine/engine.dart:194 if (batchCount(pid) >= maxBatch) return null; dart // lib/ui/home.dart:624 final dupShow = status != null && cnt < Engine.maxBatch;

What to fix

findings/S10_product.md § S10-F10 — The batch cap is silent: the button just disappears: none inside R6 (raising the cap or explaining it are both product decisions). Report the cap and the silence; the number 3 is a choice worth putting in front of Serge with the fryer case.

How to prove it

findings/S10_product.md § S10-F10 — The batch cap is silent: the button just disappears: n/a — this is a reported product decision.

S10-F11 · The app ships seeded with one specific restaurant's menu, removable only one dish at a time

What is wrong

The app ships seeded with one specific restaurant's menu, removable only one dish at a time

File and line

lib/engine/store.dart:296, :326-343 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · agent_reports/S10_refute.md · research/01_prior_work.md §2.4. Verified verbatim, including const oven = 'Cascade', fryer = 'Chirp'. Prior work records this as a deliberate divergence from the v2 decision to seed general examples — the code comment calls it "The pilot kitchen's real timers, in service order". These are dish names, not a restaurant identifier, so source_hygiene_test.dart passes; the store-listing question is separate.

findings/S10_product.md § S10-F11 — The app ships seeded with one specific restaurant's menu, removable only one dish at a time: dart // lib/engine/store.dart:326-343 e.timers = [ d('Manouche', 45, oven), d('Mozzarella sticks', 135, fryer), d('Fries', 260, fryer), d('Crispy', 375, fryer), d('Melt cheese', 210, oven), d('Dough', 720, oven), TimerDef(... name: 'Cook chicken', ... steps: [Cook 360, Flip 90, Cook 360]), ];

What to fix

findings/S10_product.md § S10-F11 — The app ships seeded with one specific restaurant's menu, removable only one dish at a time: none inside R6 — what the app seeds is a product decision. Report the options with their costs: keep the seed as a demonstration and add a one-tap clear (a feature); replace it with 2-3 neutral examples as v2 chose (a content change, no code shape change, and the phrase-repair migration already keyed to these exact names at store.dart:187-195 would need to keep working for tablets already in the field); or ship empty and rely on _empty() (home.dart:706-721), which already exists and says «Aucun timer — appuyez sur + Nouveau».

How to prove it

findings/S10_product.md § S10-F11 — The app ships seeded with one specific restaurant's menu, removable only one dish at a time: test/store_test.dart already asserts the seed runs once; whichever content is chosen, that test pins it.

S10-F12 · The operator has no in-app record of what happened; the only record is a French-only diagnostic file

What is wrong

The operator has no in-app record of what happened; the only record is a French-only diagnostic file

File and line

lib/ui/modals.dart:673-689, :698-717; the hard-coded French journal lines at lib/ui/home.dart:358, :368, :383, :396, :408 are independent of store.lang at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · agent_reports/S10_refute.md. Confirmed. Related: S13-F1 (that same export carries operator free text and a device description off the device unredacted).

findings/S10_product.md § S10-F12 — The operator has no in-app record of what happened; the only record is a French-only diagnostic file: dart // lib/ui/home.dart:358-360 Journal.log('depart ${engine.labelFor(t.id)}', '${t.totalSec} s${t.isChain ? ' (${t.steps!.length} etapes)' : ''}' '${engine.isClone(id) ? ' [lot]' : ''}'); dart // lib/ui/modals.dart:705-711 await SharePlus.instance.share(ShareParams( files: [XFile(path)], subject: 'Cadence log — ...',

What to fix

findings/S10_product.md § S10-F12 — The operator has no in-app record of what happened; the only record is a French-only diagnostic file: none inside R6 — an operator history view is a feature. What is inside R6 and worth recording: the journal's French-only strings are correct for its stated purpose (Serge's diagnostics) and should stay that way; do not "fix" them by localising, because a bilingual log is harder to read back.

How to prove it

findings/S10_product.md § S10-F12 — The operator has no in-app record of what happened; the only record is a French-only diagnostic file: n/a — reported product gap.

S10-F4 · The "add a batch" chip sits in the slap zone of a ringing tile

What is wrong

The "add a batch" chip sits in the slap zone of a ringing tile

File and line

lib/ui/tile.dart:384 (showDup = widget.dupShow && !widget.editing), lib/ui/home.dart:624 (status != null && cnt < Engine.maxBatch, which does not exclude ringing) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · agent_reports/S10_refute.md. The code fact is confirmed exactly as cited. The geometry argument is weaker than stated: on the reference 602×332 tile the chip measures ≈73×48 px including padding, sitting immediately right of the name at roughly one quarter down the tile, and Flutter resolves a touch to one point, not a palm footprint. The consequence is self-announcing — the mis-tap starts a pan and the original alarm keeps ringing — so the cook is told within a second.

findings/S10_product.md § S10-F4 — The "add a batch" chip sits in the slap zone of a ringing tile: dart // lib/ui/home.dart:624 final dupShow = status != null && cnt < Engine.maxBatch; dart // lib/ui/tile.dart:384 final showDup = widget.dupShow && !widget.editing; Ringing is not excluded in either expression. Compare the ✕ button, where the same risk was recognised and handled: tile.dart:622-623// ZERO extra hit on the ± side — that gap is a safety buffer.

What to fix

findings/S10_product.md § S10-F4 — The "add a batch" chip sits in the slap zone of a ringing tile: exclude ringing from dupShow in home.dart:624, exactly as editing is already excluded in tile.dart:384. Defect repair; it removes no capability, since batching a dish that is ringing is not a stated flow anywhere in the code or the README.

How to prove it

findings/S10_product.md § S10-F4 — The "add a batch" chip sits in the slap zone of a ringing tile: a test asserting dupShow == false when status == RunStatus.ringing — the expression currently makes it true.

S10-F6 · After any outage the board understates how late a dish is, by the whole outage

What is wrong

After any outage the board understates how late a dish is, by the whole outage

File and line

lib/engine/engine.dart:279 (rangAt = n), lib/ui/tile.dart:199-201 (count-up from it); driftMs computed at engine.dart:278 and journaled at home.dart:288-292 but absent from TileView's parameters (tile.dart:12-34) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · agent_reports/S10_refute.md. S10's premise is its own reading and the code says the opposite verbatim: // count-up baseline = the ACTUAL ring instant, not the deadline. That is a written decision and F6's proposed fix inverts it without refuting it. The honest residue, which stands: lateness is computed, journaled and never shown anywhere in the UI. Implement the residue, not the inversion.

findings/S10_product.md § S10-F6 — After any outage the board understates how late a dish is, by the whole outage: dart // lib/engine/engine.dart:279 r.rangAt = n; // count-up baseline = the ACTUAL ring instant, not the deadline dart // lib/ui/tile.dart:199-201 default: // ringing timeText = fmtUp((widget.nowMs - (r!.rangAt ?? widget.nowMs)) / 1000.0);

What to fix

findings/S10_product.md § S10-F6 — After any outage the board understates how late a dish is, by the whole outage: the display already has everything it needs — the count-up base could be endsAt-derived rather than rangAt-derived when driftMs shows the ring was late. This is a display correction of an existing computed value, not a new feature.

How to prove it

findings/S10_product.md § S10-F6 — After any outage the board understates how late a dish is, by the whole outage: a test that arms a timer, advances the fake clock past the deadline by 10 minutes, ticks once, and asserts the displayed count-up is ~10:00 rather than 0:00 — red today.

S10-F7 · Nothing on the board says which station or which cook a timer belongs to

What is wrong

Nothing on the board says which station or which cook a timer belongs to

File and line

lib/engine/models.dart:29-49 (the whole TimerDef surface — no station, owner or timestamp; legacyZoneId at :37-39, :78 is migration-only); lib/engine/store.dart:311-317 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · agent_reports/S10_refute.md §4. The gap is CONFIRMED. Correction to the history: what v0.4.11 deleted was not a colour dot — the removed widget printed z.name.toUpperCase() inside a coloured pill on every tile (git show 07ee62a -- lib/ui/tile.dart). Do not re-add a dot; the prior art is a text pill.

findings/S10_product.md § S10-F7 — Nothing on the board says which station or which cook a timer belongs to: grep -n "legacyZoneId\|zoneId" lib/engine/models.dart returns four lines, all migration-only (greps.txt §F). grep -rniE "station|owner|assign" over lib/ returns no code line (greps.txt §A).

What to fix

findings/S10_product.md § S10-F7 — Nothing on the board says which station or which cook a timer belongs to: three shapes exist and the choice is Serge's. (a) Bring zones back — refuted by L1's field observation, and the observation stands: the failure was that one intention was split across two screens, so any return must keep the sound on the timer. (b) A free-text station label on TimerDef, edited in the same editor beside the name, printed under the name on the tile — keeps L1's single-screen rule, costs one field and one line of tile text, and gives no grouping or filtering. (c) Ordering as the station signal, with the drag-reorder that already exists (home.dart:500-546) plus a visible group divider — zero data model change, but nothing enforces it and a reorder silently breaks it. (b) is the only one that survives L1's reasoning at low cost; it is still a feature and belongs in the roadmap, not in this audit's fixes.

How to prove it

findings/S10_product.md § S10-F7 — Nothing on the board says which station or which cook a timer belongs to: a widget test asserting the station text renders on the tile for a timer that carries one — currently unwritable, the field does not exist.

S10-F8 · Correcting a mistimed start costs one tap per ten seconds, and is impossible once the dish rings

What is wrong

Correcting a mistimed start costs one tap per ten seconds, and is impossible once the dish rings

File and line

lib/ui/tile.dart:601-616, :569-570; lib/engine/engine.dart:228-241 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · agent_reports/S10_refute.md. No repeat-on-hold exists anywhere. Sub-claim refuted: S10 says the alarm_backstop.dart:36-39 debounce now "defends against a burst the UI can no longer produce". Wrong — S10's own table says +5 min costs 30 taps, and 30 taps in a few seconds is exactly the burst the 300 ms debounce collapses. Only the word holding in that comment is stale; the debounce is still load-bearing — do not remove it.

findings/S10_product.md § S10-F8 — Correcting a mistimed start costs one tap per ten seconds, and is impossible once the dish rings: dart // lib/ui/tile.dart:569-570 final visible = status == 'running' || status == 'paused'; if (!visible) return const SizedBox.shrink(); dart // lib/ui/tile.dart:601-608 _CtlBtn( sign: '+', label: '10', onTap: widget.onPlus, Note that alarm_backstop.dart:36-39 records a burst of "~5×/second" from holding ±10 s — that behaviour belonged to the webapp prototype; in this Flutter build the button is a plain onTap and holding it does nothing at all. The 300 ms debounce it justifies is now defending against a burst the UI can no longer produce.

What to fix

CORRECTED FIX: retain the 300 ms debounce. It collapses the proven burst from repeated ±10-second taps.

How to prove it

findings/S10_product.md § S10-F8 — Correcting a mistimed start costs one tap per ten seconds, and is impossible once the dish rings: for the documentation half, no test; for the feature, a roadmap item.

S10-F9 · The most urgent state on the board is the least visible one

What is wrong

The most urgent state on the board is the least visible one

File and line

lib/ui/theme.dart:16 and :25 are byte-identical Color(0xFFE7DED0); lib/ui/tile.dart:778-779 sweeps p * 2 * math.pi, so the coloured area → 0 as the deadline arrives at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · agent_reports/S10_refute.md. Confirmed, and the refuter notes S10 correctly names the artifact that would settle the remaining design question rather than hedging.

findings/S10_product.md § S10-F9 — The most urgent state on the board is the least visible one: theme.dart:16 static const tileIdle = Color(0xFFE7DED0); and theme.dart:25 static const track = Color(0xFFE7DED0); — identical values. theme.dart:59-69 fillFor returns red below p = 0.15, and tile.dart:778-779 sweeps the sector by p * 2 * math.pi, so the red area shrinks to zero as the deadline arrives.

What to fix

findings/S10_product.md § S10-F9 — The most urgent state on the board is the least visible one: none inside R6 — any change here is a visual design decision. Report it as a decision to revisit with the numbers above, not as a defect.

How to prove it

findings/S10_product.md § S10-F9 — The most urgent state on the board is the least visible one: a rendered-board screenshot at 12 and 20 dishes with one tile at p = 0.05, graded by Serge at 1.5 m. Naming what would settle it: this judgement genuinely depends on seeing it run; a headless test cannot decide it.

S11-F2 · Two of the three font families ship with their embedded licence record stripped, and no record of where the binaries came from

What is wrong

Two of the three font families ship with their embedded licence record stripped, and no record of where the binaries came from

File and line

assets/fonts/BigShouldersDisplay-{Medium,Bold,ExtraBold}.ttf and the three ChivoMono faces — six files at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S11_asset_licensing.md · agent_reports/S11_refute.md §4. The stripping is real and reproduced. But upstream name ID 13 is a one-sentence notice plus a URL, not the OFL text — and name ID 14 carries that same URL and is already present in the bundled files. "The licence travels nowhere" is wrong. Correct the sentence before publishing.

findings/S11_asset_licensing.md § S11-F2 — Two of the three font families ship with their embedded licence record stripped, and no record of where the binaries came from: name records present in the official Chivo Mono static and absent from the bundled copy (proof/01_findings/S11/07_chivomono_upstream.txt vs 03_font_name_tables.txt): official gf_ChivoMono-Regular.ttf [ 7 trademark ] Chivo is a trademark of Omnibus-Type. [ 8 manufacturer ] Omnibus-Type [ 9 designer ] Hector Gatti [11 vendorURL ] https://www.omnibus-type.com [12 designerURL ] https://www.omnibus-type.com [13 license ] This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: https://scripts.sil.org/OFL [14 licenseURL ] https://scripts.sil.org/OFL bundled assets/fonts/ChivoMono-Regular.ttf [14 licenseURL ] https://scripts.sil.org/OFL (records 7, 8, 9, 11, 12, 13, 25 absent) Same pattern for Big Shoulders Display, whose upstream variable master carries 8, 9, 11, 12, 13 and 25 (proof/01_findings/S11/08_bigshoulders_upstream.txt). By contrast DSEG7Classic-Bold.ttf is byte-identical to its official release and still contains the entire OFL text in record 13 (proof/01_findings/S11/06_dseg_upstream.txt).

What to fix

findings/S11_asset_licensing.md § S11-F2 — Two of the three font families ship with their embedded licence record stripped, and no record of where the binaries came from: replace the six files with the pristine upstream releases — Chivo Mono from the Google Fonts family download (https://fonts.google.com/download/list?family=Chivo%20Mono, static/ChivoMono-{Regular,Medium,Bold}.ttf), Big Shoulders Display by instancing ofl/bigshouldersdisplay/BigShouldersDisplay[wght].ttf from github.com/google/fonts at weights 500/700/800 with updateFontNames=True so records 0-6 and 13-14 survive. Record the source URL, version and SHA-256 of every font in LICENSES.md (the draft already has the table). Expect roughly +26 KB across the six files; that is the price of shipping the licence inside the binary.

How to prove it

findings/S11_asset_licensing.md § S11-F2 — Two of the three font families ship with their embedded licence record stripped, and no record of where the binaries came from: red now, green after: for f in assets/fonts/BigShouldersDisplay-*.ttf assets/fonts/ChivoMono-*.ttf; do python -c " import sys from fontTools.ttLib import TTFont ids = {r.nameID for r in TTFont(sys.argv[1])['name'].names} assert 13 in ids, '%s has no licence record' % sys.argv[1] " "$f" || exit 1 done It currently fails on the first file and passes on DSEG7Classic-Bold.ttf.

S11-F4 · The web target ships the Flutter logo as the product's own icon, with no BSD-3-Clause notice

What is wrong

The web target ships the Flutter logo as the product's own icon, with no BSD-3-Clause notice

File and line

web/favicon.png, web/icons/Icon-192.png, web/icons/Icon-512.png, web/icons/Icon-maskable-192.png, web/icons/Icon-maskable-512.png at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S11_asset_licensing.md · agent_reports/S11_refute.md §6 · agent_reports/S9_refute.md §M02. All five icons are now byte-proven, not three: the two maskable icons were fetched from flutter_template_images 5.0.0 (sha256 0120589a…) and are BIT-IDENTICAL; S11's "byte comparison unavailable" limitation was not real. The trademark question is answered definitively from Google's own guidelines, which S11 never fetched. S9's refuter reached the opposite conclusion about three of the five — see CONTRADICTIONS.md §4.

findings/S11_asset_licensing.md § S11-F4 — The web target ships the Flutter logo as the product's own icon, with no BSD-3-Clause notice: proof/01_findings/S11/10_web_icons_flutter_template.txt: BIT-IDENTICAL web/favicon.png sha=7ab2525f4b86b65d3e4c70358a17e5a1aaf6f437f99cbcc046dad73d59bb9015 BIT-IDENTICAL web/icons/Icon-192.png sha=3dce99077602f70421c1c6b2a240bc9b83d64d86681d45f2154143310c980be3 BIT-IDENTICAL web/icons/Icon-512.png sha=baccb205ae45f0b421be1657259b4943ac40c95094ab877f3bcbe12cd544dcbe compared against https://raw.githubusercontent.com/flutter/flutter/3.44.8/packages/flutter_tools/templates/app/web/{favicon.png,icons/Icon-192.png,icons/Icon-512.png}.copy.tmpl (retrieved 2026-08-04). Icon-192.png and Icon-512.png also carry an embedded PNG tIME chunk reading 2020-01-07, the Flutter template's own build date.

What to fix

findings/S11_asset_licensing.md § S11-F4 — The web target ships the Flutter logo as the product's own icon, with no BSD-3-Clause notice: replace all five with the Cadence artwork already in assets/icon/, and add the Flutter BSD-3-Clause notice to LICENSES.md for as long as any template asset remains (the draft already carries the text in §2).

How to prove it

findings/S11_asset_licensing.md § S11-F4 — The web target ships the Flutter logo as the product's own icon, with no BSD-3-Clause notice: red now, green after — for f in web/favicon.png web/icons/*.png; do shasum -a 256 "$f"; done must produce no hash in {7ab2525f…, 3dce9907…, baccb205…, d2c842e2…, 6aee06cd…}.

S11-F5 · The application has no licence surface at all

What is wrong

The application has no licence surface at all

File and line

lib/ (whole tree — no showLicensePage/LicenseRegistry call site exists); Settings dialog at lib/ui/modals.dart:616-693 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S11_asset_licensing.md · agent_reports/S11_refute.md §3.3. Verified from the Flutter SDK source at the pinned version, not from the API doc: LicenseRegistry/showLicensePage would surface nothing for these fonts even if called.

findings/S11_asset_licensing.md § S11-F5 — The application has no licence surface at all: proof/01_findings/S11/11_no_licence_ui.txtgrep -rnE 'showLicensePage|showAboutDialog|AboutDialog|LicenseRegistry|LicensePage|addLicense' lib/ test/ android/ ios/ web/ exits 1 with no output. The bundle that goes unread: assets/flutter_assets/NOTICES.Z, 114,355 bytes compressed / 1,381,653 characters expanded (proof/01_findings/S11/09_apk_notices.txt).

What to fix

findings/S11_asset_licensing.md § S11-F5 — The application has no licence surface at all: one row in the existing Settings dialog opening showLicensePage, with applicationName: 'Cadence' and applicationVersion: kAppVersion (lib/main.dart:20), and an LicenseRegistry.addLicense call in main() yielding the three font notices from an asset. Add the FR and EN strings to lib/i18n.dart alongside the existing settings keys, per R9.

How to prove it

findings/S11_asset_licensing.md § S11-F5 — The application has no licence surface at all: a widget test that opens Settings, taps the new row and asserts a LicensePage is on screen and that the rendered text contains Open Font License. It cannot even be written today because no such row exists.

S12-F10 · Three `setState` calls run after an `await` with no `mounted` guard

What is wrong

Three setState calls run after an await with no mounted guard

File and line

lib/ui/home.dart:448 and :456 (after await showTimerEditor at :421), and :491 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S12_lifecycle.md · agent_reports/S12_refute.md §6. The refuter re-verified every setState/await/mounted triple and confirmed all three sites are unguarded. It classes the finding among the seven "REFUTED in part — the code fact holds; the harm claim, the classification, or the severity does not" without stating a replacement grade, so MEDIUM is carried forward with that caveat attached. Same latency argument as the other lifecycle rows: dispose() has one production trigger and it destroys the isolate.

findings/S12_lifecycle.md § S12-F10 — Three setState calls run after an await with no mounted guard: verbatim, and the contrast with the guarded sites in the same file: dart // lib/ui/home.dart:443-456 — unguarded if (!saved) { Diag.fail('save-def', ...); setState(() {}); // :448 return; } ... setState(() {}); // :456 dart // lib/ui/home.dart:166,168 — guarded, same file, same author if (mounted) setState(() {}); Recorded grep proof/01_findings/S12/grep_setstate_mounted.txt lists every setState, mounted and await in the file; lines 421/448/456 and 467/491 are the three awaitsetState pairs with no mounted between them.

What to fix

findings/S12_lifecycle.md § S12-F10 — Three setState calls run after an await with no mounted guard: if (!mounted) return; immediately after each await, matching the pattern already used at :166, :168, :191, :398.

How to prove it

findings/S12_lifecycle.md § S12-F10 — Three setState calls run after an await with no mounted guard: a widget test that opens the editor with showTimerEditor, unmounts HomeScreen while the sheet is open, then completes the sheet's future. Red now (the setState throws), green after. ---

S12-F2 · Delayed announcement timers are never held and outlive `dispose()`

What is wrong

Delayed announcement timers are never held and outlive dispose()

File and line

lib/ui/home.dart:275-281, specifically :277 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S12_lifecycle.md · agent_reports/S12_refute.md §2 · proof/01_findings/S12_refute/repro_s12_w0_ticker_live.txt. Reproduced red (EXIT_CODE=1). One of the three S12 findings confirmed as written: the code genuinely has no cancel path, the fix is four lines, the window is real. Latent, because _HomeScreenState.dispose() has exactly one production trigger at 03a176e and it destroys the isolate.

findings/S12_lifecycle.md § S12-F2 — Delayed announcement timers are never held and outlive dispose(): verbatim source: dart // lib/ui/home.dart:275-281 void _announceIfStill(String id, Duration delay, bool Function(RunEntry r) ok, String Function() text) { Timer(delay, () { final r = engine.run[id]; if (r != null && ok(r)) voice.enqueue(id, text()); }); } Recorded run proof/01_findings/S12/w0_ticker_live_and_announce_timer_leak.txt (EXIT_CODE=1) — the alarm is made to fire one tick before the unmount, and the binding names the survivor: Timer (duration: 0:00:00.900000, periodic: false), created: #5 _HomeScreenState._announceIfStill (package:cadence/ui/home.dart:277:5) #6 _HomeScreenState.onAlarmFire (package:cadence/ui/home.dart:294:5) #7 Engine._fireAlarm (package:cadence/engine/engine.dart:286:10) #8 Engine.tick (package:cadence/engine/engine.dart:331:13) #9 _HomeScreenState._boot.<anonymous closure> (package:cadence/ui/home.dart:165:14) ... A Timer is still pending even after the widget tree was disposed.

What to fix

findings/S12_lifecycle.md § S12-F2 — Delayed announcement timers are never held and outlive dispose(): hold the handles and cancel them. Applied and verified on the copy — proof/01_findings/S12/fix_announce_timer_leak.patch: dart final Set<Timer> _announceTimers = {}; ... late final Timer t; t = Timer(delay, () { _announceTimers.remove(t); ... }); _announceTimers.add(t); ... // in dispose() for (final t in _announceTimers) { t.cancel(); } _announceTimers.clear();

How to prove it

findings/S12_lifecycle.md § S12-F2 — Delayed announcement timers are never held and outlive dispose(): proof/01_findings/S12/tests/s12_w0_ticker_live_test.dart. Red now (w0_ticker_live_and_announce_timer_leak.txt, EXIT_CODE=1); green with the patch applied (w0_AFTER_fix_green.txt, 00:01 +3: All tests passed!, EXIT_CODE=0), and the app's own 123 tests stay green (baseline_suite_AFTER_fix.txt, EXIT_CODE=0). ---

S12-F6 · A wedged TTS utterance leaks a native channel result on both platforms

What is wrong

A wedged TTS utterance leaks a native channel result on both platforms

File and line

android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:35, :143-165 (esp. :147, :160-162); ios/Runner/AppDelegate.swift:28, :173, :182, :188-189 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S12_lifecycle.md · agent_reports/S12_refute.md §2. The only LIVE leak of the six S12 claimed. Trigger narrowed: MainActivity.kt:150 calls speak(..., TextToSpeech.QUEUE_FLUSH, ...), so the next utterance interrupts the previous one and fires onStop(id, interrupted)completeSpeak, clearing the stale entry. The leak therefore requires an engine that accepts speak() with SUCCESS and then emits no UtteranceProgressListener callback at all — not merely "swallows a callback". Rewrite the trigger description.

findings/S12_lifecycle.md § S12-F6 — A wedged TTS utterance leaks a native channel result on both platforms: verbatim source: kotlin // MainActivity.kt:35 private val pendingSpeaks = HashMap<String, MethodChannel.Result>() // MainActivity.kt:160-162 — the only per-utterance removal private fun completeSpeak(id: String, ok: Boolean) { main.post { pendingSpeaks.remove(id)?.success(ok) } } dart // lib/audio/voice.dart:166-173 — Dart gives up, native is never told await _ch .invokeMethod('speak', {'text': item.text, 'volume': vol}) .timeout(const Duration(seconds: 12)); } on TimeoutException { Diag.fail('voice-speak', 'native speak timed out (engine wedged?)'); } The measured announcement volume that would feed it: 1,145 alarm fires and 1,145 repeats in a 12-hour service (proof/01_findings/S12/soak_engine_journal.txt, SOAK A1).

What to fix

findings/S12_lifecycle.md § S12-F6 — A wedged TTS utterance leaks a native channel result on both platforms: on TimeoutException in _drain, send _ch.invokeMethod('stop') before continuing; native stop already calls completeAllSpeaks() (MainActivity.kt:104-106, AppDelegate.swift:127-129).

How to prove it

findings/S12_lifecycle.md § S12-F6 — A wedged TTS utterance leaks a native channel result on both platforms: add to test/voice_test.dart a cadence/tts mock whose speak never completes, drive one enqueue, advance 13 s, and assert the recorded call list now contains stop. Red now, green after. ---

S12-F8 · Nothing in the app mitigates OLED burn-in on a static board held at full brightness for twelve hours

What is wrong

Nothing in the app mitigates OLED burn-in on a static board held at full brightness for twelve hours

File and line

lib/main.dart:28 (WakelockPlus.enable()), :33 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S12_lifecycle.md · agent_reports/S12_refute.md §3.2. The grep is confirmed (nothing mitigates burn-in; nothing names a panel). But the finding assumes an OLED panel and the audit establishes the panel nowhere — a 50/50 premise stated as certainty. The exact missing artefact: one field journal from the pilot tablet, whose fourth line is APPAREIL <manufacturer> <model> … (lib/journal.dart:119-120). The precise test: read that model number, look it up on the manufacturer's product page, record the display technology. OLED → MEDIUM stands. LCD → drops to LOW as image-persistence only. Separately: proposed fix (b) (a black overlay dismissed by touch) is a new end-user feature and violates R6, and fix (a) (shifting the grid origin ±3 px) moves every touch target and belongs to S4.

findings/S12_lifecycle.md § S12-F8 — Nothing in the app mitigates OLED burn-in on a static board held at full brightness for twelve hours: recorded run proof/01_findings/S12/grep_burn_in_mitigation.txt: COMMAND: grep -rniE 'brightness|burn.?in|screensaver|pixel.?shift|dim(ming)?|idle.?timeout|standby' lib/ android/app/src/main/ ... EXIT_CODE=1 Exit 1 with no output — zero matches across all 4,853 lines of lib/ and the whole Android source tree. The only idle tokens in the codebase are the tile status string and the colour token C.tileIdle (proof/01_findings/S12/grep_burn_in_mitigation.txt is the negative; the positives are visible in lib/ui/tile.dart:103,182,207,225).

What to fix

findings/S12_lifecycle.md § S12-F8 — Nothing in the app mitigates OLED burn-in on a static board held at full brightness for twelve hours: in scope as defect repair, not a feature. (a) Shift the whole grid origin by a few pixels on a slow cycle — GridLayout.solve already centres the grid (lib/ui/home.dart:573), so adding a ±3 px offset that advances once a minute costs nothing and breaks the static pattern. (b) When engine.run.isEmpty for more than N minutes, drop the screen's luminance (a full-screen black Opacity overlay dismissed by the first touch) — this is the same idle state S12-F7 wants for power. Both are behaviour on an existing screen, no new user-facing capability.

How to prove it

findings/S12_lifecycle.md § S12-F8 — Nothing in the app mitigates OLED burn-in on a static board held at full brightness for twelve hours: a widget test that renders HomeScreen at two times a minute apart and asserts the Offset returned for tile 0 differs; and a test that asserts the dim overlay is present after the idle threshold with an empty run map and absent with a non-empty one. ---

S12R-F1 · Three per-tile trigger maps in `_HomeScreenState` grow without bound during a session, with no teardown required

What is wrong

Three per-tile trigger maps in _HomeScreenState grow without bound during a session, with no teardown required

File and line

lib/ui/home.dart:54-56 (declarations of _flash, _justOn, _spawn), :319, :361, :414, :415 (the only writes; zero removal sites) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S12_refute.md §5. This refutes S12's own headline. S12 says the journal is "the one unbounded thing"; these three Map<String,int> are keyed by tile id, Engine.uid() (engine.dart:60-62) mints a fresh id per clone, and nothing ever removes an entry. They grow live, with no teardown required.

agent_reports/S12_refute.md § S12R-F1 — Three per-tile trigger maps in _HomeScreenState grow without bound during a session, with no teardown required: verbatim declarations — dart // lib/ui/home.dart:53-56 // one-shot animation triggers per tile id (bump = play once) final Map<String, int> _flash = {}; final Map<String, int> _justOn = {}; final Map<String, int> _spawn = {}; every use site, recorded run proof/01_findings/S12_refute/grep_ui_trigger_maps_no_removal.txt: 54: final Map<String, int> _flash = {}; 55: final Map<String, int> _justOn = {}; 56: final Map<String, int> _spawn = {}; 319: _flash[t.id] = (_flash[t.id] ?? 0) + 1; 361: _justOn[id] = (_justOn[id] ?? 0) + 1; 414: _spawn[cloneId] = (_spawn[cloneId] ?? 0) + 1; 415: _justOn[cloneId] = (_justOn[cloneId] ?? 0) + 1; 641: flashTick: _flash[id] ?? 0, 642: justOnTick: _justOn[id] ?? 0, 643: spawnTick: _spawn[id] ?? 0, and the negative, recorded run proof/01_findings/S12_refute/grep_ui_trigger_maps_removal_sites.txt: COMMAND: grep -rnE '_(flash|justOn|spawn)\.(remove|clear)' lib/ EXIT_CODE=1 Exit 1, no output — zero removal sites in the whole of lib/. The volume is S12's own recorded measurement: clonesSpawned=192 in a 12-hour service (proof/01_findings/S12_refute/repro_s12_soak.txt, SOAK A1), i.e. 384 dead entries per service, accumulating for as long as the tablet is not restarted.

What to fix

agent_reports/S12_refute.md § S12R-F1 — Three per-tile trigger maps in _HomeScreenState grow without bound during a session, with no teardown required: clear the three entries when the tile they belong to stops existing. _dup already knows the clone id; the dissolution point is Engine.stopTimerhost.onStopped(id) (engine.dart:225), which _HomeScreenState already implements at home.dart:338. Make it void onStopped(String id) { voice.stopFor(id); if (engine.isClone(id)) { _flash.remove(id); _justOn.remove(id); _spawn.remove(id); } }, and add the same three removals to the res.delete branch of _openEditor (:431-433).

How to prove it

agent_reports/S12_refute.md § S12R-F1 — Three per-tile trigger maps in _HomeScreenState grow without bound during a session, with no teardown required: a widget test that starts a dish, spawns a batch, stops the batch, repeats the cycle N times, and asserts that the tile-trigger state does not grow with N. Because the maps are private, assert through TileView: after N cycles the number of distinct ValueKeys that have ever carried a non-zero spawnTick is unbounded now and equals the live tile count after the fix. Red now, green after.

S12R-F4 · Every S12 proof file stamps `TREE_STATE: CLEAN`, including the three recorded against a mutated or patched tree

What is wrong

Every S12 proof file stamps TREE_STATE: CLEAN, including the three recorded against a mutated or patched tree

File and line

proof/01_findings/S12/*.txt — all 14; cause stated at findings/S12_lifecycle.md:542-543 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S12_refute.md §5. S12 ran every command with CADENCE_REPO pointed at the pinned repo while working in a copy, so the stamp certifies the wrong tree. The mirror image of the S1/S2 defect: there the harness climbed out of the copy by accident; here it was pinned out deliberately. Both produce a TREE_STATE line that says nothing about the tree under test. See EVIDENCE_INTEGRITY.md §2.

agent_reports/S12_refute.md § S12R-F4 — Every S12 proof file stamps TREE_STATE: CLEAN, including the three recorded against a mutated or patched tree: $ grep -l "TREE_STATE: DIRTY" proof/01_findings/S12/*.txt NONE — every S12 proof file claims CLEAN $ head -9 proof/01_findings/S12/mutation_soak_goes_red.txt COMMAND: flutter test test/s12_soak_test.dart -r expanded CWD: a scratch working copy GIT_HEAD: 03a176e72ef0075eec86b8915cbe6e93042a3b9d TREE_STATE: CLEAN REPO: the app repository The command ran in s12_copy; the header describes cadence-app. For contrast, my own runs stamp the tree they executed against — proof/01_findings/S12_refute/r1_journal_reconcile.txt: CWD: a scratch working copy REPO: a scratch working copy TREE_STATE: DIRTY (2 path(s) modified) TREE_DIFF: ?? test/s12r_journal_reconcile_test.dart ?? test/s12r_line_cost_test.dart

What to fix

agent_reports/S12_refute.md § S12R-F4 — Every S12 proof file stamps TREE_STATE: CLEAN, including the three recorded against a mutated or patched tree: for the audit — re-record the three affected files with CADENCE_REPO unset and the copy initialised as its own git repository. For run_and_record.sh — when CADENCE_REPO is set but does not contain $(pwd -P), emit REPO_MISMATCH in the header instead of a clean stamp.

How to prove it

agent_reports/S12_refute.md § S12R-F4 — Every S12 proof file stamps TREE_STATE: CLEAN, including the three recorded against a mutated or patched tree: re-run mutation_soak_goes_red.txt under the same mutation from a copy that is its own repository and assert the header reads TREE_STATE: DIRTY. Red now, green after.

S12R-F5 · S12's mutation record does not meet R8 on three of its four conditions

What is wrong

S12's mutation record does not meet R8 on three of its four conditions

File and line

proof/01_findings/S12/mutation_stoptimer_keeps_clone.patch, mutation_soak_goes_red.txt; claim at findings/S12_lifecycle.md:641-659 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S12_refute.md §5. Same class as S3R-F4, S7R-F2, and S5's two failed mutations.

agent_reports/S12_refute.md § S12R-F5 — S12's mutation record does not meet R8 on three of its four conditions: $ ls proof/01_findings/S12/ | grep -c json 0 $ grep -c "MUTATION (S12 R8)" proof/01_findings/S12/mutation_stoptimer_keeps_clone.patch 2 and the run itself shows three failing tests from that single patch file (mutation_soak_goes_red.txt: Failing tests: lists SOAK A1, SOAK A2 and SOAK A3). For contrast, proof/01_findings/S12_refute/mutations/MUT-R2_backstop_drops_posee_line_json.txt meets all four: id 1 -> loading … result= success hidden= True id 3 -> R1 — S12 profile: home.dart-only vs full … result= failure hidden= False id 4 -> R2 — journal bytes/hour across service intensities result= success hidden= False $ git status --porcelain lib/ # after revert LIB_RESTORED_CLEAN

What to fix

agent_reports/S12_refute.md § S12R-F5 — S12's mutation record does not meet R8 on three of its four conditions: split the combined patch into one patch per named test, re-run each under --reporter=json, and record git status --porcelain after each revert.

How to prove it

agent_reports/S12_refute.md § S12R-F5 — S12's mutation record does not meet R8 on three of its four conditions: three JSON records, each with a failing set of exactly one test.

S12R-F6 · The soak's headline journal-growth figure is defended by no assertion

What is wrong

The soak's headline journal-growth figure is defended by no assertion

File and line

proof/01_findings/S12/tests/s12_soak_test.dart:321-341 — the only journal assertion in SOAK A1 is expect(file.lengthSync(), greaterThan(0)) at line 341; the 27,585 B/h is printed at line 323 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S12_refute.md §3.3, §5. The soak can go red, but not on the number it exists to produce. This is why the growth figure was wrong and nothing caught it.

Replay the named proof at agent_reports/S12_refute.md §3.3, §5 against `proof/01_findings/S12/tests/s12_soak_test.dart:321-341 — the only journal assertion in SOAK A1 is expect(file.lengthSync(), greaterThan(0)) at line 341; the 27,585 B/h is printed at line 323on a disposable copy of03a176e72ef0075eec86b8915cbe6e93042a3b9d`.

What to fix

agent_reports/S12_refute.md § S12R-F6 — The soak's headline journal-growth figure is defended by no assertion: assert a band on the derived rate, e.g. expect(perHour, inInclusiveRange(26000, 29000)), so any change to the emitted line set fails the soak.

How to prove it

agent_reports/S12_refute.md § S12R-F6 — The soak's headline journal-growth figure is defended by no assertion: apply MUT-R1_journal_ts_drops_ms.patch and show SOAK A1 goes red. ---

S13-F6 + S2-F12 → see the `S2-F12 + S13-F6` row above · Corrupt-value siblings persist forever with no expiry and no way to clear them

What is wrong

Corrupt-value siblings persist forever with no expiry and no way to clear them

File and line

lib/engine/store.dart:119-128 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S13_data.md · agent_reports/S13_refute.md. Row retained so the S13 ID is not lost. Both streams' refuters confirmed the same defect at the same lines.

findings/S13_data.md § S13-F6 — Corrupt-value siblings persist forever with no expiry and no way to clear them: proof/01_findings/S13/probe_data.txt, PROBE-A — the probe deliberately corrupted a key and the sibling appears alongside it in the final store: PREFKEY|cadence-clones-v1|String|{not json PREFKEY|cadence-clones-v1.corrupt|String|{not json and the guard that makes it permanent (lib/engine/store.dart:122): dart if (_readString('$key.corrupt') == null) { _guard('$key.corrupt', prefs.setString('$key.corrupt', raw)); } findings/S2_persistence.md § S2-F12 — The .corrupt preservation is write-only: nothing reads it, exports it, or restores from it, and a second corruption is discarded: grep, whole-tree: $ grep -rn "corrupt" lib --include="*.dart" | grep -v "lib/engine/store.dart" lib/i18n.dart:141: // An unknown lang value (corrupt store) falls back to English — never a lib/engine/engine.dart:70: /// Then every run entry must hold its STRUCTURAL invariants — a corrupt lib/audio/alarm_volume.dart:42: /// Any value → a level we are willing to ring at. A corrupt/absent value All three hits are prose in comments. Recorded run proof/01_findings/S2/04_write_concurrency.txt: W4 DEFECT — the preserved `.corrupt` copy is unreachable preserved 75 bytes under "cadence-timers-v1.corrupt" — the only reader of this key is store.dart:122 after a second, different corruption the .corrupt copy is still: [{"id":"a","name":"Fries",... W5 — board healthy again; stale .corrupt key still present = true

What to fix

findings/S13_data.md § S13-F6 — Corrupt-value siblings persist forever with no expiry and no way to clear them: delete the sibling once the live key parses cleanly again — a two-line addition inside _readList and the run-decode block after a successful parse. That preserves the entire point of the mechanism (the operator or Serge can still recover the raw value while the problem is live) while bounding retention to the duration of the fault. findings/S2_persistence.md § S2-F12 — The .corrupt preservation is write-only: nothing reads it, exports it, or restores from it, and a second corruption is discarded: in scope for this audit (R6 permits compliance/diagnostics plumbing, not new end-user features): append the .corrupt payloads to the journal at load time, so they leave the device via the export the operator already performs — Journal.log('DONNEE CORROMPUE $key', raw) inside _preserveCorrupt. Restoring from a .corrupt key in the UI is a new user-facing capability and is REPORTED, not built: spec — a "Restaurer" row in Réglages, visible only when a .corrupt sibling exists, which parses it with the same entry-level salvage and replaces the current list.

How to prove it

findings/S13_data.md § S13-F6 — Corrupt-value siblings persist forever with no expiry and no way to clear them: a store_test.dart case that corrupts cadence-timers-v1, loads, asserts the .corrupt sibling exists, then writes a valid value, loads again, and asserts the sibling is gone. Red today (it persists). --- findings/S2_persistence.md § S2-F12 — The .corrupt preservation is write-only: nothing reads it, exports it, or restores from it, and a second corruption is discarded: a new test seeding cadence-timers-v1 with {{{corrupt, calling Store.load, and asserting the exported journal file contains DONNEE CORROMPUE cadence-timers-v1. Red now (no such line is written), green after. ---

S13-R1 (S13's refuter) · The no-network proof has no layer covering native (non-Dart) network paths, and two plugins have them

What is wrong

The no-network proof has no layer covering native (non-Dart) network paths, and two plugins have them

File and line

lib/audio/audio.dart:73; audioplayers_darwin-6.5.0/darwin/…/WrappedMediaPlayer.swift:165-185; audioplayers_android-5.3.0/…/source/UrlSource.kt at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S13_refute.md §3. S13's §3 layer 3 enumerates packages importing package:http and shows each import site is unreachable. That method cannot see a plugin that opens a connection from Swift or Kotlin, because such a plugin imports no Dart HTTP library at all. The no-network conclusion survives — it is safe for a store listing — but the proof does not cover the case, and a store filing must rest on a proof that does.

agent_reports/S13_refute.md § S13-R1 — The network proof has no layer covering native (non-Dart) network paths, and two plugins have them: proof/01_findings/S13_refute/privacy_manifest_and_network.txt §7: audioplayers_android-5.3.0 : NATIVE NETWORK-CAPABLE -> audioplayers_android-5.3.0/android/src/main/kotlin/xyz/luan/audioplayers/source/UrlSource.kt audioplayers_darwin-6.5.0 : NATIVE NETWORK-CAPABLE -> audioplayers_darwin-6.5.0/darwin/audioplayers_darwin/Sources/audioplayers_darwin/WrappedMediaPlayer.swift (all twelve other plugins: none) The iOS one is a genuine remote fetch, WrappedMediaPlayer.swift:169-181: swift let parsedUrl = isLocal ? URL(fileURLWithPath: url.deletingPrefix("file://")) : URL(string: url) … let asset = AVURLAsset(url: parsedUrl, …) playerItem = AVPlayerItem(asset: asset) An AVURLAsset built from an http(s) URL streams over the network without touching URLSession or any Dart HTTP package. The app is safe because the branch is unreachable: AssetSource is the only source it ever constructs, and it is constructed in exactly one place — the app repository/lib/audio/audio.dart:73: await p.play(AssetSource('audio/$asset')); with UrlSource, DeviceFileSource, BytesSource, AudioCache, setSourceUrl, setSourceDeviceFile and setSourceBytes all absent from lib/. §8 of the same proof file.

What to fix

agent_reports/S13_refute.md § S13-R1 — The network proof has no layer covering native (non-Dart) network paths, and two plugins have them: add layer 5 to §3 — the native-source grep above, with its output — and state the reachability argument for audioplayers as its own line rather than a cell in the http table.

How to prove it

agent_reports/S13_refute.md § S13-R1 — The network proof has no layer covering native (non-Dart) network paths, and two plugins have them: a source_hygiene_test.dart case asserting lib/ contains no UrlSource, DeviceFileSource, BytesSource or AudioCache reference. Green today, and it goes red the day someone adds a remote sound.

S13-R3 (S13's refuter) · S13-F2's store-declaration rationale is wrong, and following it would over-declare on both stores

What is wrong

S13-F2's store-declaration rationale is wrong, and following it would over-declare on both stores

File and line

S13-F2 "Why it matters"; S13 §1 preamble at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S13_refute.md §3 · proof/03_market/captures/s813r_play_data_safety_expanded.txt. Google's published guidance, verbatim: "Collect means transmitting data from your app off a user's device", and "Developers do not have to declare data access as collection if it occurs solely on the user's device", plus an FAQ covering this exact shape (a user's own upload to their own cloud account governed by that provider's terms is not the app's collection). S13 also mis-frames its personal-data table as the input to the Data Safety form and Apple nutrition labels; it is a GDPR-controller inventory, a different question with a different answer.

agent_reports/S13_refute.md § S13-R3 — S13-F2's store-declaration rationale is wrong, and following it would over-declare on both stores: capture proof/03_market/captures/s813r_play_data_safety_expanded.txt, URL https://support.google.com/googleplay/android-developer/answer/10787469?hl=en, retrieved 2026-08-04, §"Data collection": > "Collect" means transmitting data from your app off a user's device. > Note: Developers do not have to declare data access as collection if it occurs solely on the user's > device as long as the data is never transmitted off the user's device. and, on the exact shape of the backup case: > My app enables users to upload their data directly to Google Drive or Dropbox for backup or > storage. My app does not access any of this data. Should that still be disclosed as "collection"? > It depends on the particular implementation. If the user chooses to upload their data directly to > their own external drive or cloud storage account (such as Google Drive, Dropbox, or similar > services) and this upload is governed by the external drive or cloud storage provider's terms of > service and privacy policy, and your app never collects or accesses the data in question, then your > app does not need to declare the collection of this data. Apple's definition is quoted in §1 above and gives the same answer. Auto Backup is performed by the Android OS into the tablet owner's own Google account under Google's terms; Serge never receives or accesses it. Neither is the share-sheet export, whose destination the operator chooses.

What to fix

agent_reports/S13_refute.md § S13-R3 — S13-F2's store-declaration rationale is wrong, and following it would over-declare on both stores: rewrite the "Why it matters" paragraph; keep the fix (dataExtractionRules over allowBackup="false", per S13-R2). Correct §1's framing to "GDPR controller inventory", and add one line recording that both store forms answer "no data collected", with the two definitions quoted.

How to prove it

agent_reports/S13_refute.md § S13-R3 — S13-F2's store-declaration rationale is wrong, and following it would over-declare on both stores: not a code test — the two quoted definitions above are the artifact.

S14-F4 · The operator banner promised for a failed wakelock DOES exist and is reachable — but it can never clear, and a wakelock that silently fails to take effect produces no banner at all

What is wrong

The operator banner promised for a failed wakelock DOES exist and is reachable — but it can never clear, and a wakelock that silently fails to take effect produces no banner at all

File and line

lib/main.dart:26-32, lib/ui/home.dart:670-707 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S14_entry_unowned.md · agent_reports/S14_refute.md §2.3. S14 proved the banner with a hand-made Diag.fail. The refuter drove the real main() with a failing wakelock backend and proved both residual defects by test rather than grep. Note S14R-M1 found an ERROR-severity type hole on lib/main.dart:31, the exact line this finding is about.

findings/S14_entry_unowned.md § S14-F4 — The operator banner promised for a failed wakelock DOES exist and is reachable — but it can never clear, and a wakelock that silently fails to take effect produces no banner at all: the banner is proven reachable by test T3 in proof/01_findings/S14/s14_main_test.dart, recorded green in proof/01_findings/S14/probe_main_green.txt. It pumps the real CadenceApp, asserts the banner is absent, then makes exactly the call lib/main.dart:31 makes: dart expect(find.textContaining('Keep-awake unavailable'), findsNothing); Diag.fail('wakelock', 'simulated', isCritical: true); await tester.pump(); expect(find.textContaining('Keep-awake unavailable'), findsOneWidget, reason: 'lib/ui/home.dart:683-684 maps scope "wakelock" to screenDown'); The run output carries the matching debugPrint: [cadence] wakelock: simulated. The banner string is '⚠️ Keep-awake unavailable — the screen can turn off' (lib/i18n.dart:123) / '⚠️ Maintien de l\'écran indisponible — l\'écran peut s\'éteindre' (lib/i18n.dart:77-78). For the two defects: $ grep -rn "clearCritical" lib/ | grep wakelock (no output) $ grep -rn "WakelockPlus" lib/ lib/main.dart:28: WakelockPlus.enable().then((_) {

What to fix

findings/S14_entry_unowned.md § S14-F4 — The operator banner promised for a failed wakelock DOES exist and is reachable — but it can never clear, and a wakelock that silently fails to take effect produces no banner at all: two changes, both defect repair. (1) On success, call Diag.clearCritical('wakelock') inside the existing .then at lib/main.dart:28-29, next to the journal line. (2) Re-assert and verify on resume: in _HomeScreenState.didChangeAppLifecycleState (lib/ui/home.dart:175), on AppLifecycleState.resumed, await WakelockPlus.enabled and, if false, call enable() again and route the outcome through Diag.fail / Diag.clearCritical. S12 owns kiosk endurance; this finding owns the call site.

How to prove it

findings/S14_entry_unowned.md § S14-F4 — The operator banner promised for a failed wakelock DOES exist and is reachable — but it can never clear, and a wakelock that silently fails to take effect produces no banner at all: a test that sets the critical scope, then drives the success path, and asserts the banner disappears — expect(find.textContaining('Keep-awake unavailable'), findsNothing) after Diag.clearCritical('wakelock') is reached. Red at 03a176e (the call does not exist), green after. ---

S14-F8 → **reassigned to S9** · `.metadata` registers `web` for template migration but not `android` or `ios`, so the two platforms that ship are excluded from every future Flutter template upgrade

What is wrong

.metadata registers web for template migration but not android or ios, so the two platforms that ship are excluded from every future Flutter template upgrade

File and line

.metadata:13-20 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S14_entry_unowned.md · agent_reports/S14_refute.md §1, §3 S14R-M3. Diffed against a fresh template by the refuter. Reassigned per S14R-M3: S14 audited a 47-file scope deleted from the plan before Phase 1 launched.

findings/S14_entry_unowned.md § S14-F8 — .metadata registers web for template migration but not android or ios, so the two platforms that ship are excluded from every future Flutter template upgrade: proof/01_findings/S14/metadata_vs_template.diff [not published], a diff against a freshly generated project — the template has four platform blocks, this repository has two: diff - platform: root - - platform: android - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: ios - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - platform: web $ git log --oneline -- .metadata 22902e0 Cadence v0.2.0 — app Flutter (moteur + UI + audio natif) avec lot robustesse

What to fix

findings/S14_entry_unowned.md § S14-F8 — .metadata registers web for template migration but not android or ios, so the two platforms that ship are excluded from every future Flutter template upgrade: add the - platform: android and - platform: ios blocks with create_revision/base_revision set to the revision already recorded at .metadata:7 (84fc5cbb223bc12f83d65b647ff8a56caf779ffd), and delete the web block together with web/ (F7). Compliance plumbing, in scope under R6.

How to prove it

findings/S14_entry_unowned.md § S14-F8 — .metadata registers web for template migration but not android or ios, so the two platforms that ship are excluded from every future Flutter template upgrade: flutter migrate --verbose lists android and ios among the platforms it considers. At 03a176e it does not. ---

S14R-M1 · The analyser's strict type-checking modes were never measured, and turning them on surfaces six ERROR-severity type holes, one on the exact line S14 wrote F4 about

What is wrong

The analyser's strict type-checking modes were never measured, and turning them on surfaces six ERROR-severity type holes, one on the exact line S14 wrote F4 about

File and line

analysis_options.yaml:1-29 — the absence of an analyzer: block is the finding; the errors it hides include lib/main.dart:31 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S14_refute.md §3. S14-F5 measured one dimension of the file (what sits under linter: rules:) and concluded from 75 lint infos that the analyser is "switched almost off". The other dimension — analyzer: language: controlling strict-casts, strict-inference, strict-raw-types, which are not lints — was never touched, and turning it on surfaces six ERROR-severity type holes.

agent_reports/S14_refute.md § S14R-M1 — The analyser's strict type-checking modes were never measured, and turning them on surfaces six ERROR-severity type holes, one of them on the exact line S14 wrote finding F4 about: the whole analyzer: block added to a pristine copy, no source change (proof/01_findings/S14_refute/analysis_options_strict_modes.yaml [not published]), run recorded in proof/01_findings/S14_refute/analyze_strict_modes.txt13 issues, 6 of them error: error • The argument type 'dynamic' can't be assigned to the parameter type 'Object'. • lib/main.dart:31:27 • argument_type_not_assignable error • The argument type 'dynamic' can't be assigned to the parameter type 'Map<dynamic, dynamic>'. • lib/engine/models.dart:76:72 • argument_type_not_assignable error • The argument type 'dynamic' can't be assigned to the parameter type 'Object'. • lib/engine/store.dart:142:32 • argument_type_not_assignable error • The argument type 'dynamic' can't be assigned to the parameter type 'Object'. • lib/engine/store.dart:178:34 • argument_type_not_assignable error • The argument type 'dynamic' can't be assigned to the parameter type 'Object'. • lib/audio/voice.dart:197:33 • argument_type_not_assignable error • The argument type 'dynamic' can't be assigned to the parameter type 'Object'. • lib/alarm_backstop.dart:231:36 • argument_type_not_assignable plus six inference_failure_on_untyped_parameter warnings on the matching catchError((e) { lines. The one that carries runtime risk is lib/engine/models.dart:76: dart : (j['steps'] as List) .map((s) => StepDef.fromJson(Map<String, dynamic>.from(s))) s is dynamic. If a persisted timer's steps array holds anything that is not a map — the exact situation lib/engine/store.dart's _preserveCorrupt path exists for — Map.from(s) throws at load time. strict-casts names it statically today; nothing does now.

What to fix

agent_reports/S14_refute.md § S14R-M1 — The analyser's strict type-checking modes were never measured, and turning them on surfaces six ERROR-severity type holes, one of them on the exact line S14 wrote finding F4 about: add to analysis_options.yaml, alongside the lint rules chosen in §2.5: yaml analyzer: language: strict-casts: true strict-inference: true strict-raw-types: true and fix the 13 sites — six by typing the catchError parameter (Object e), one by giving models.dart:76 an explicit as Map check, the rest mechanical. Compliance plumbing, in scope under R6.

How to prove it

agent_reports/S14_refute.md § S14R-M1 — The analyser's strict type-checking modes were never measured, and turning them on surfaces six ERROR-severity type holes, one of them on the exact line S14 wrote finding F4 about: flutter analyze --fatal-infos --fatal-warnings must exit 0 with the strict block present. It exits 1 with 13 issues at 03a176e — recorded above. Then remove the explicit type from one catchError parameter and confirm flutter analyze returns to non-zero, proving the gate is live rather than merely present.

S14R-M3 · S14 audited a 47-file scope that was deleted from the plan before Phase 1 launched, so five of its findings belong to other streams and its partition reconciliation contradicts the partition tool

What is wrong

S14 audited a 47-file scope that was deleted from the plan before Phase 1 launched, so five of its findings belong to other streams and its partition reconciliation contradicts the partition tool

File and line

findings/S14_entry_unowned.md header and its git ls-files reconciliation section, against PLAN.md:175, CHECKLIST.md:50 and :67, tools/check_partition.py, research/04_partition.md:46-47 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S14_refute.md §3. This is the authority for the five reassignments applied in this register (F6→S10, F7→S6, F8→S9, F9→S9, F11→S7). It changes what Phase 2 and Phase 4 must do, not what the app does. S14's own "144 files map to exactly one stream; S14 absorbs 47 including pubspec.yaml/pubspec.lock" claim is REFUTED by four independent artefacts.

agent_reports/S14_refute.md § S14R-M3 — S14 audited a 47-file scope that was deleted from the plan before Phase 1 launched, so five of its findings belong to other streams and its partition reconciliation contradicts the partition tool: proof/01_findings/S14_refute/check_partition_rerun.txt (✅ PASS — partition is total, 144 tracked, 0 unowned) and proof/01_findings/S14_refute/partition_ownership_diff.txt: S14 self-assigned : 47 canonical S14 files : 3 FILES S14 CLAIMED WHOSE CANONICAL PRIMARY IS ANOTHER STREAM: 44 by true owner: {'S11': 17, 'S9': 16, 'S6': 9, 'S10': 1, 'S7': 1} and, verbatim from tools/check_partition.py: python # --- S14 · app entry and startup ordering (named files only — no catch-all) ------------------ Claim("lib/main.dart", "S14", "58 lines, 0.00% covered, owns the startup sequence and the wakelock failure path"), Claim("ios/Runner/SceneDelegate.swift", "S14", "the iOS scene lifecycle entry point, the counterpart of main.dart"), Claim("analysis_options.yaml", "S14", "the static-analysis configuration the whole toolchain boots from, named to S14 in PLAN.md §4"),

What to fix

agent_reports/S14_refute.md § S14R-M3 — S14 audited a 47-file scope that was deleted from the plan before Phase 1 launched, so five of its findings belong to other streams and its partition reconciliation contradicts the partition tool: (1) re-title findings/S14_entry_unowned.md and restate its scope as the three canonical files; (2) move F6 → S10, F7 → S6, F8 → S9, F9 → S9, F11 → S7, keeping the evidence verbatim, and have each owning stream reconcile against its own findings rather than accept them wholesale; (3) delete the git ls-files reconciliation section from the S14 file — partition.txt produced by tools/check_partition.py is the artefact that settles this, and a second, divergent copy of it is worse than none; (4) keep F1–F5 and F10 under S14, since lib/main.dart and analysis_options.yaml are genuinely S14's.

How to prove it

agent_reports/S14_refute.md § S14R-M3 — S14 audited a 47-file scope that was deleted from the plan before Phase 1 launched, so five of its findings belong to other streams and its partition reconciliation contradicts the partition tool: a per-finding owner column in findings/S14_entry_unowned.md that matches awk over proof/00_baseline/partition.txt for each finding's Location: path. It disagrees on five findings today. ---

S2-C2 · An unknown or absent run status is silently coerced to `running`

What is wrong

An unknown or absent run status is silently coerced to running

File and line

lib/engine/models.dart:132not :130-131 as S2 cited; the orElse: clause is on line 132. Reached from lib/engine/store.dart:73 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md. The coercion is real. Second correction: the outcome is not always "not dropped" — see the refuter's blast-radius row B6. S1's refuter independently confirmed :131-132 is the sole read that silently defaults.

Replay the named proof at findings/S2_persistence.md · agent_reports/S2_refute.md against lib/engine/models.dart:132` — **not** `:130-131` as S2 cited; the `orElse:` clause is on line 132. Reached from `lib/engine/store.dart:73 on a disposable copy of 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

What to fix

Implement the correction stated in findings/S2_persistence.md · agent_reports/S2_refute.md; do not change the pinned evidence location lib/engine/models.dart:132` — **not** `:130-131` as S2 cited; the `orElse:` clause is on line 132. Reached from `lib/engine/store.dart:73 without updating the regression citation.

How to prove it

Add a regression named S2-C2 that reproduces “An unknown or absent run status is silently coerced to running” at 03a176e72ef0075eec86b8915cbe6e93042a3b9d and asserts the opposite after the fix; run it in the whole suite under the mutation-specificity gate in §8.

S2-F1 · A wrong-TYPE stored value destroys the entire kitchen configuration silently: no backup, no banner, and the next save makes it permanent

What is wrong

A wrong-TYPE stored value destroys the entire kitchen configuration silently: no backup, no banner, and the next save makes it permanent

File and line

lib/engine/store.dart:34-41 and :93 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md §2. Code and behaviour verified independently (timers=0, backup=null, banner={}, then saveDefs writes []). But the trigger is not production-reachable — nothing in the app writes a wrong-typed value to those keys. S2-F3 falls with it; S2-F9 does not, because its refuter found a reachable trigger needing no wrong type.

findings/S2_persistence.md § S2-F1 — A wrong-TYPE stored value destroys the entire kitchen configuration silently: no backup, no banner, and the next save makes it permanent: the guard that skips preservation, verbatim — dart // store.dart:34-41 String? _readString(String key) { try { return prefs.getString(key); } catch (e) { Diag.fail('load-$key', 'stored value has wrong type: $e'); // NOT isCritical return null; } } // store.dart:91-93 List<T> _readList<T>(String key, T Function(Map<String, dynamic>) parse) { final raw = _readString(key); if (raw == null) return []; // <- indistinguishable from "never stored" Recorded run proof/01_findings/S2/01_corruption_matrix.txt: ROW | A1 malformed JSON (garbage prefix) | threw=false | recovered=0/3 | corruptKeyWritten=true | criticalBanner=true ROW | A10 WRONG PREFS TYPE: int stored under a String key | threw=false | recovered=0/3 | corruptKeyWritten=false | criticalBanner=false ROW | A11 WRONG PREFS TYPE: bool stored under a String key | threw=false | recovered=0/3 | corruptKeyWritten=false | criticalBanner=false ROW | A12 WRONG PREFS TYPE: List<String> under a String key | threw=false | recovered=0/3 | corruptKeyWritten=false | criticalBanner=false CONFIRMED: timers=0, corrupt-backup=null, critical={}, scopes=[load-cadence-timers-v1] after saveDefs: cadence-timers-v1=[] | corrupt-sibling=null The existing test robustness_test.dart:79 ("wrong TYPES under our keys → boot survives with fallbacks") asserts only expect(e.timers, isEmpty). It asserts the data loss as the correct outcome and never checks for preservation or a banner.

What to fix

findings/S2_persistence.md § S2-F1 — A wrong-TYPE stored value destroys the entire kitchen configuration silently: no backup, no banner, and the next save makes it permanent: in _readString/_readBool/_readDouble, distinguish "absent" from "wrong type". Give _readString an out-parameter or a sentinel (e.g. return a ({String? value, bool typeError}) record), and in _readList and the run block call _preserveCorrupt(key, '<wrong type: ${prefs.get(key)}>', err) on the type-error branch so the raw value is copied aside via prefs.get(key).toString() and the failure is raised isCritical: true, matching the JSON path.

How to prove it

findings/S2_persistence.md § S2-F1 — A wrong-TYPE stored value destroys the entire kitchen configuration silently: no backup, no banner, and the next save makes it permanent: s2_corruption_matrix_test.dart, test "DEFECT — a wrong-TYPE prefs value loses ALL timers with NO .corrupt backup and NO operator banner" currently passes because it asserts the defect; invert its three expectations to expect(store.prefs.getString('cadence-timers-v1.corrupt'), isNotNull) and expect(Diag.critical.value, contains('load-cadence-timers-v1')). Red now, green after. ---

S2-F10 · `repairGeneratedPhrases` deletes the operator's own sentence if it re-runs

What is wrong

repairGeneratedPhrases deletes the operator's own sentence if it re-runs

File and line

lib/engine/store.dart:219-231, flag at :229, doc comment at :215-216 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md. Same "_guard never verifies" wording defect as S2-F2 — correct it. Interacts with S8-F2 (the generated phrase this migration writes is grammatically wrong in both languages).

findings/S2_persistence.md § S2-F10 — repairGeneratedPhrases deletes the operator's own sentence if it re-runs: recorded run proof/01_findings/S2/02_migration.txt: repairGeneratedPhrases has the same unverified one-shot flag re-run cleared 1 phrase(s); phrase is now ""

What to fix

findings/S2_persistence.md § S2-F10 — repairGeneratedPhrases deletes the operator's own sentence if it re-runs: report the one-shot flag failures as critical — change _guard's signature to void _guard(String key, Future<bool> write, {bool isCritical = false}) and pass isCritical: true for _kSeeded, _kPhraseRepair and _kZoneSound; and move line 229 so the flag is only written after a confirmed saveDefs.

How to prove it

findings/S2_persistence.md § S2-F10 — repairGeneratedPhrases deletes the operator's own sentence if it re-runs: s2_migration_test.dart, test "repairGeneratedPhrases has the same unverified one-shot flag". Invert to expect(e.timers.first.phrase, 'The fries are ready'). Red now, green after. ---

S2-F11 · A zone whose `sound` field has the wrong type is skipped in silence and its dishes fall back to `Bell`

What is wrong

A zone whose sound field has the wrong type is skipped in silence and its dishes fall back to Bell

File and line

lib/engine/store.dart:264-268, specifically :267 (if (id is String && s is String) tones[id] = s; with no else) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md. Probe reproduces (diagScopes=[]).

findings/S2_persistence.md § S2-F11 — A zone whose sound field has the wrong type is skipped in silence and its dishes fall back to Bell: recorded run proof/01_findings/S2/02_migration.txt: DEFECT 3 — a zone whose sound field is not a String is skipped in silence moved=3 | sounds={Fries: Bell, Crispy: Bell, Dough: Chime} | diagScopes=[] The fritteuse zone (sound: 42) took both its dishes with it; the four zone survived; Diag recorded nothing.

What to fix

findings/S2_persistence.md § S2-F11 — A zone whose sound field has the wrong type is skipped in silence and its dishes fall back to Bell: add the missing branch: dart if (id is String && s is String) { tones[id] = s; } else { Diag.fail('migrate-zone-sound', 'zone entry unreadable: $m', isCritical: true); }

How to prove it

findings/S2_persistence.md § S2-F11 — A zone whose sound field has the wrong type is skipped in silence and its dishes fall back to Bell: s2_migration_test.dart, test "DEFECT 3". Invert expect(Diag.log, isEmpty) to expect(Diag.log.map((d) => d.scope), contains('migrate-zone-sound')). Red now, green after. ---

S2-F12 + S13-F6 · The `.corrupt` preservation is write-only: nothing reads it, exports it, or restores from it; a second corruption is discarded; the siblings never expire and cannot be cleared

What is wrong

The .corrupt preservation is write-only: nothing reads it, exports it, or restores from it; a second corruption is discarded; the siblings never expire and cannot be cleared

File and line

lib/engine/store.dart:119-128; the only reader is :122 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · findings/S13_data.md · agent_reports/S2_refute.md. Found twice — S2 from the persistence side, S13 from the data-retention side. S2's refuter re-ran the whole-tree grep and got the same three hits, all prose in comments (i18n.dart:141, engine/engine.dart:70, audio/alarm_volume.dart:42). S2R-M4 makes it worse: the .corrupt write itself is unverified and non-critical.

findings/S13_data.md § S13-F6 — Corrupt-value siblings persist forever with no expiry and no way to clear them: proof/01_findings/S13/probe_data.txt, PROBE-A — the probe deliberately corrupted a key and the sibling appears alongside it in the final store: PREFKEY|cadence-clones-v1|String|{not json PREFKEY|cadence-clones-v1.corrupt|String|{not json and the guard that makes it permanent (lib/engine/store.dart:122): dart if (_readString('$key.corrupt') == null) { _guard('$key.corrupt', prefs.setString('$key.corrupt', raw)); } findings/S2_persistence.md § S2-F12 — The .corrupt preservation is write-only: nothing reads it, exports it, or restores from it, and a second corruption is discarded: grep, whole-tree: $ grep -rn "corrupt" lib --include="*.dart" | grep -v "lib/engine/store.dart" lib/i18n.dart:141: // An unknown lang value (corrupt store) falls back to English — never a lib/engine/engine.dart:70: /// Then every run entry must hold its STRUCTURAL invariants — a corrupt lib/audio/alarm_volume.dart:42: /// Any value → a level we are willing to ring at. A corrupt/absent value All three hits are prose in comments. Recorded run proof/01_findings/S2/04_write_concurrency.txt: W4 DEFECT — the preserved `.corrupt` copy is unreachable preserved 75 bytes under "cadence-timers-v1.corrupt" — the only reader of this key is store.dart:122 after a second, different corruption the .corrupt copy is still: [{"id":"a","name":"Fries",... W5 — board healthy again; stale .corrupt key still present = true

What to fix

findings/S13_data.md § S13-F6 — Corrupt-value siblings persist forever with no expiry and no way to clear them: delete the sibling once the live key parses cleanly again — a two-line addition inside _readList and the run-decode block after a successful parse. That preserves the entire point of the mechanism (the operator or Serge can still recover the raw value while the problem is live) while bounding retention to the duration of the fault. findings/S2_persistence.md § S2-F12 — The .corrupt preservation is write-only: nothing reads it, exports it, or restores from it, and a second corruption is discarded: in scope for this audit (R6 permits compliance/diagnostics plumbing, not new end-user features): append the .corrupt payloads to the journal at load time, so they leave the device via the export the operator already performs — Journal.log('DONNEE CORROMPUE $key', raw) inside _preserveCorrupt. Restoring from a .corrupt key in the UI is a new user-facing capability and is REPORTED, not built: spec — a "Restaurer" row in Réglages, visible only when a .corrupt sibling exists, which parses it with the same entry-level salvage and replaces the current list.

How to prove it

findings/S13_data.md § S13-F6 — Corrupt-value siblings persist forever with no expiry and no way to clear them: a store_test.dart case that corrupts cadence-timers-v1, loads, asserts the .corrupt sibling exists, then writes a valid value, loads again, and asserts the sibling is gone. Red today (it persists). --- findings/S2_persistence.md § S2-F12 — The .corrupt preservation is write-only: nothing reads it, exports it, or restores from it, and a second corruption is discarded: a new test seeding cadence-timers-v1 with {{{corrupt, calling Store.load, and asserting the exported journal file contains DONNEE CORROMPUE cadence-timers-v1. Red now (no such line is written), green after. ---

S2-F13 · Every uncovered line in `store.dart` is a failure handler, and five of them are the write-failure paths

What is wrong

Every uncovered line in store.dart is a failure handler, and five of them are the write-failure paths

File and line

lib/engine/store.dart:133-147 and :174-180; the README claim at README.md:14 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md. lcov reproduced exactly on a pristine copy: store.dart 118/125 = 94.40 %, uncovered [47,126,137,142,145,176,178]; journal.dart 85/104 = 81.73 %; diagnostics.dart 19/22 = 86.36 %, uncovered [15,16,20]; total 858/1927 = 44.53 %. The refuter executed line 137 for the first time and the branch behaves as designed.

findings/S2_persistence.md § S2-F13 — Every uncovered line in store.dart is a failure handler, and five of them are the write-failure paths; the README's "écriture immédiate" is a claim about initiation only: lcov extraction from coverage/lcov.info after the unmodified baseline run (123 tests, reproducing the pinned 44.53% total): lib/engine/store.dart: 118/125 = 94.40% UNCOVERED LINES: [47, 126, 137, 142, 145, 176, 178] lib/journal.dart: 85/104 = 81.73% UNCOVERED LINES: [110, 118, 119, 120, 121, 124, 125, 126, 164, 176, 192, 194, 195, 196, 197, 199, 201, 230, 234] lib/diagnostics.dart: 19/22 = 86.36% UNCOVERED LINES: [15, 16, 20] The unverifiable write, verbatim: dart // store.dart:133-147 void _write(String key, String json) { try { prefs.setString(key, json).then((ok) { … }).catchError((e) { … }); } catch (e) { … } } Recorded run proof/01_findings/S2/04_write_concurrency.txt: W1 — saveDefs return type is void; the only failure signal is an asynchronous Diag.fail on scope "save-cadence-timers-v1".

What to fix

findings/S2_persistence.md § S2-F13 — Every uncovered line in store.dart is a failure handler, and five of them are the write-failure paths; the README's "écriture immédiate" is a claim about initiation only: inject the writer so failures are testable — add Future<bool> Function(String, String) writeString = ... as an overridable field on Store defaulting to prefs.setString, then add tests that stub it to return false and to throw, asserting Diag.critical.value contains save-cadence-timers-v1 in both cases and that Diag.clearCritical fires on a subsequent success. Amend README.md:14 to (9 clés + siblings.corrupt, écriture déclenchée immédiatement, confirmation asynchrone).

How to prove it

findings/S2_persistence.md § S2-F13 — Every uncovered line in store.dart is a failure handler, and five of them are the write-failure paths; the README's "écriture immédiate" is a claim about initiation only: the new stub-based tests cover lines 137, 142, 145, 176, 178; re-run flutter test --coverage and assert store.dart reaches 100.00%. Currently those lines are DA:…,0. ---

S2-F14 · Deleting the migration's persistence step passes all 123 existing tests (mutation S2-M1)

What is wrong

Deleting the migration's persistence step passes all 123 existing tests (mutation S2-M1)

File and line

lib/engine/store.dart:280 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md §4. Reproduced from scratch.

findings/S2_persistence.md § S2-F14 — Deleting the migration's persistence step passes all 123 existing tests (mutation S2-M1): mutation patch saved at proof/01_findings/S2/mutation_M1.patch: diff --- store.dart.orig +++ lib/engine/store.dart @@ -280 +280 @@ - if (moved > 0) saveDefs(e); + // MUTATION S2-M1: migration no longer persists the inherited tones Recorded run proof/01_findings/S2/06_mutation_M1_existing_suite.txt — the full baseline suite under the mutation: 00:02 +123: All tests passed! EXIT_CODE=0 Recorded run proof/01_findings/S2/07_mutation_M1_probe_goes_red.txt — the S2 regression test added for this finding, under the same mutation: 00:00 +2 -1: GAP — nothing in the suite asserts the migration PERSISTS the tones [E] Expected: {'Fries': 'Beep', 'Crispy': 'Beep', 'Dough': 'Chime'} Actual: {'Fries': null, 'Crispy': null, 'Dough': null} EXIT_CODE=1 The same test is green on unmutated code (proof/01_findings/S2/02_migration.txt, 11/11 pass).

What to fix

findings/S2_persistence.md § S2-F14 — Deleting the migration's persistence step passes all 123 existing tests (mutation S2-M1): adopt s2_migration_test.dart's test "GAP — nothing in the suite asserts the migration PERSISTS the tones" into test/store_test.dart's migration group. It decodes prefs.getString('cadence-timers-v1') after the call and asserts the tone of each dish.

How to prove it

findings/S2_persistence.md § S2-F14 — Deleting the migration's persistence step passes all 123 existing tests (mutation S2-M1): re-apply mutation_M1.patch and confirm flutter test exits non-zero. It exits 0 today. ---

S2-F15 · The journal header cannot distinguish two builds of the same version, which is the exact failure this commit set out to fix

What is wrong

The journal header cannot distinguish two builds of the same version, which is the exact failure this commit set out to fix

File and line

lib/journal.dart:83, sourced from lib/main.dart:20 (const String kAppVersion = '0.4.12';); pubspec.yaml:5 is 0.4.12+18 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md. version_test.dart compares .split('+').first and its own comment states the omission verbatim.

findings/S2_persistence.md § S2-F15 — The journal header cannot distinguish two builds of the same version, which is the exact failure this commit set out to fix: recorded run proof/01_findings/S2/03_journal_diagnostics.txt: J6 — the journal header cannot distinguish two builds of the same version HEADER: SESSION 2026-08-04 12:03:39.062 · Cadence v0.4.12 pubspec declares "0.4.12+18"

What to fix

findings/S2_persistence.md § S2-F15 — The journal header cannot distinguish two builds of the same version, which is the exact failure this commit set out to fix: carry the full version+build string in the header. Keep kAppVersion hand-maintained but make it the full '0.4.12+18', and widen test/version_test.dart to compare against the untrimmed line.split(':')[1].trim() rather than .split('+').first, so the guard that already exists covers the build number too.

How to prove it

findings/S2_persistence.md § S2-F15 — The journal header cannot distinguish two builds of the same version, which is the exact failure this commit set out to fix: s2_journal_diag_test.dart, test "J6". Invert expect(header, isNot(contains('+'))) to expect(header, contains(pubspecVersion)). Red now, green after. ---

S2-F16 · Rotation is checked only at `init`, so a kiosk session that never restarts passes the 3 MiB cap

What is wrong

Rotation is checked only at init, so a kiosk session that never restarts passes the 3 MiB cap

File and line

lib/journal.dart:72; cap _maxBytes = 3 * 1024 * 1024 = 3,145,728 at :25 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md §5 · agent_reports/S12_refute.md §1. The cap exists, is checked only at init, and the MEDIUM grade holds. S2's "34.1 days to the cap" is wrong. S2's refuter re-measured the byte sizes and got 20.7 days; S12's refuter reconciled the whole model against production emitters and got 13.0 days at 242,270 bytes/day. Replace every digit S2 published except the 167-byte session header and the 3,145,728-byte cap. Full reconciliation in CONTRADICTIONS.md §3.

findings/S2_persistence.md § S2-F16 — Rotation is checked only at init, so a kiosk session that never restarts passes the 3 MiB cap after ~34 days: measured growth, recorded run proof/01_findings/S2/08_journal_growth.txt, using the verbatim line shapes emitted by lib/ui/home.dart:358, 289, 307, 368: MEASURED: session header = 167 bytes MEASURED: one dish cycle (start + alarm + 3 repeats + stop) = 279 bytes over 6 lines MEASURED: one written heartbeat line = 44 bytes ARITHMETIC: 210 cycles x 279 B = 58590 B ARITHMETIC: 768 beats x 44 B = 33792 B ARITHMETIC: total = 92382 B/day = 90.2 KiB/day ARITHMETIC: one week = 0.62 MiB ARITHMETIC: days to reach the 3 MiB rotation threshold = 34.1 ARITHMETIC: quiet floor (heartbeat only, 1 line / 5 min) = 86.6 KiB/week Assumptions, stated: 7 dishes fired 15× per service, 2 services/day = 210 dish cycles; heartbeat written every 60 s across 10 service hours (600) and once per 5 min across the other 14 (168) = 768 written beats/day. Unbounded growth demonstrated directly: J4 DEFECT — rotation is checked ONLY at init live journal size after 20000 events: 4460167 bytes (rotation threshold is 3145728 bytes) Verdict on the "unbounded journal" hypothesis: the growth rate is 90.2 KiB/day and the file is bounded at 3 MiB across restarts. It is unbounded only within one continuous session, and a session must run 34 days to exceed the cap. That is reachable on a kiosk tablet but it is not a near-term failure; the severity is driven by the interaction with S2-F6, where a file that has grown large is then read whole into a UTF-16 String by _rotate and destroyed on any failure.

What to fix

findings/S2_persistence.md § S2-F16 — Rotation is checked only at init, so a kiosk session that never restarts passes the 3 MiB cap after ~34 days: re-check the size inside _flush on a counter — every 500 flushes, compare _file!.lengthSync() against _maxBytes and rotate. One extra stat per 500 events is free next to the flush: true append already performed per event.

How to prove it

findings/S2_persistence.md § S2-F16 — Rotation is checked only at init, so a kiosk session that never restarts passes the 3 MiB cap after ~34 days: s2_journal_diag_test.dart, test "J4 DEFECT". Invert expect(size, greaterThan(3 * 1024 * 1024)) to expect(size, lessThan(3 * 1024 * 1024)). Red now, green after. ---

S2-F17 · `Diag.log` and `DiagEntry.toString` are dead in production: the in-RAM diagnostics the header advertises are read only by tests

What is wrong

Diag.log and DiagEntry.toString are dead in production: the in-RAM diagnostics the header advertises are read only by tests

File and line

lib/diagnostics.dart:9-22 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md. Independent greps: Diag.log has zero readers in lib/, two in test/ (store_test.dart:104, journal_test.dart:45); DiagEntry appears only inside diagnostics.dart; lcov shows 15,16,20 uncovered.

findings/S2_persistence.md § S2-F17 — Diag.log and DiagEntry.toString are dead in production: the in-RAM diagnostics the header advertises are read only by tests: grep, whole-tree: $ grep -rn "DiagEntry\|Diag\.log" lib --include="*.dart" lib/diagnostics.dart:9:class DiagEntry { lib/diagnostics.dart:13: DiagEntry(this.at, this.scope, this.message); lib/diagnostics.dart:22: static final List<DiagEntry> log = []; lib/diagnostics.dart:29: log.add(DiagEntry(DateTime.now(), scope, '$e')); $ grep -rn "Diag\.log" test --include="*.dart" test/store_test.dart:104: expect(Diag.log.map((d) => d.scope), test/journal_test.dart:45: expect(Diag.log, hasLength(1)); lcov: lib/diagnostics.dart: 19/22 = 86.36% UNCOVERED LINES: [15, 16, 20].

What to fix

findings/S2_persistence.md § S2-F17 — Diag.log and DiagEntry.toString are dead in production: the in-RAM diagnostics the header advertises are read only by tests: keep Diag.log (it is the test seam and the mechanism is sound) and mark it @visibleForTesting so the contract is explicit, matching Diag.reset at line 48. Delete DiagEntry.toString() (diagnostics.dart:15-16) — zero callers, proven above — or use it as the journal line format at diagnostics.dart:37 instead of the ad-hoc '$scope: $e', which removes the duplication (R7).

How to prove it

findings/S2_persistence.md § S2-F17 — Diag.log and DiagEntry.toString are dead in production: the in-RAM diagnostics the header advertises are read only by tests: re-run flutter test --coverage and assert lib/diagnostics.dart reaches 100.00% with lines 15-16 gone. They are DA:15,0 and DA:16,0 today. ---

S2-F18 · One failure report copies an unbounded error string into both the 50-entry buffer and the on-disk journal

What is wrong

One failure report copies an unbounded error string into both the 50-entry buffer and the on-disk journal

File and line

lib/diagnostics.dart:29 and :37 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md. Independently reproduced: in-RAM entry length 200,000.

findings/S2_persistence.md § S2-F18 — One failure report copies an unbounded error string into both the 50-entry buffer and the on-disk journal: recorded run proof/01_findings/S2/03_journal_diagnostics.txt: D3 DEFECT — an unbounded error string is copied verbatim into the 50-entry buffer AND appended to the on-disk journal one failure report added 200047 bytes to the journal; in-RAM entry length=200000

What to fix

findings/S2_persistence.md § S2-F18 — One failure report copies an unbounded error string into both the 50-entry buffer and the on-disk journal: truncate once at the choke point, in the same statement that stringifies defensively for S2-F8: dart if (msg.length > 500) msg = '${msg.substring(0, 500)}… (${msg.length} chars)';

How to prove it

findings/S2_persistence.md § S2-F18 — One failure report copies an unbounded error string into both the 50-entry buffer and the on-disk journal: s2_journal_diag_test.dart, test "D3 DEFECT". Invert expect(grew, greaterThan(190000)) to expect(grew, lessThan(1000)). Red now, green after. ---

S2-F3 · A wrong-TYPE `cadence-zones-v1` marks the migration done without running it and then deletes the legacy tones

What is wrong

A wrong-TYPE cadence-zones-v1 marks the migration done without running it and then deletes the legacy tones

File and line

lib/engine/store.dart:256-285; contradicted invariant at :260-261 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md. Mechanism confirmed and the probe reproduces (flag=true, zonesKeyStillThere=false). Same unreachable wrong-type trigger class as S2-F1, so the severity moves with it.

findings/S2_persistence.md § S2-F3 — A wrong-TYPE cadence-zones-v1 marks the migration done without running it and then deletes the legacy tones: recorded run proof/01_findings/S2/02_migration.txt: DEFECT 2 — a WRONG-TYPED zones value marks the migration DONE without ever running it moved=0 | sounds={Fries: Chirp, Crispy: Chirp, Dough: Chirp} | flag=true | zonesKeyStillThere=false | criticalBanner={} Compare the JSON path, which honours the invariant (store_test.dart:172 passes): des zones ILLISIBLES ne donnent pas un son par defaut a la cuisine → flag=null, zones key retained, banner raised

What to fix

findings/S2_persistence.md § S2-F3 — A wrong-TYPE cadence-zones-v1 marks the migration done without running it and then deletes the legacy tones: hoist the "present but unreadable" guard above the type boundary. Test key presence with prefs.containsKey(_kZones) rather than inferring absence from a null read: dart final raw = _readString(_kZones); if (raw == null && prefs.containsKey(_kZones)) { Diag.fail('migrate-zone-sound', 'zones key present but unreadable type', isCritical: true); return 0; // keep the key, retry next boot }

How to prove it

findings/S2_persistence.md § S2-F3 — A wrong-TYPE cadence-zones-v1 marks the migration done without running it and then deletes the legacy tones: s2_migration_test.dart, test "DEFECT 2". Invert its expectations to expect(store.prefs.getBool('cadence-zone-sound-v1'), isNull) and expect(store.prefs.containsKey('cadence-zones-v1'), isTrue). Red now, green after. ---

S2R-M2 · `markCleanExit` is fire-and-forget, so a clean shutdown reports itself as an OS kill

What is wrong

markCleanExit is fire-and-forget, so a clean shutdown reports itself as an OS kill

File and line

lib/journal.dart:186-190, called without await from lib/ui/home.dart:202 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S2_refute.md §7. The death detector's whole value is that !clean && lastBeat != null (journal.dart:85) means the OS killed us. markCleanExit awaits a flush: true write before writing the flag, and is called unawaited from the AppLifecycleState.detached callback — the last moment before the process dies. Every death inside that window prints !! SESSION PRECEDENTE TUEE on the next boot.

agent_reports/S2_refute.md § S2R-M2 — markCleanExit is fire-and-forget, so a clean shutdown reports itself as an OS kill: M2 cadence-journal-clean on disk when the callback returned = false M2 next boot reports "SESSION PRECEDENTE TUEE" = true

What to fix

agent_reports/S2_refute.md § S2R-M2 — markCleanExit is fire-and-forget, so a clean shutdown reports itself as an OS kill: write the clean marker first, then flush: move await _prefs?.setBool(_kClean, true); above log(…) and await _flush(); in journal.dart:186-190. A marker written and then not flushed costs one missing line; a flush done before the marker costs a false kill report. Nothing user-facing changes, so this stays inside R6.

How to prove it

agent_reports/S2_refute.md § S2R-M2 — markCleanExit is fire-and-forget, so a clean shutdown reports itself as an OS kill: my test "M2 — markCleanExit is fire-and-forget…". Invert expect(killed, isTrue) to isFalse. Red now, green after.

S2R-M3 · A `load-` critical banner can never be cleared for the life of the session

What is wrong

A load- critical banner can never be cleared for the life of the session

File and line

lib/diagnostics.dart:41-46 and its six call sites; raised at lib/engine/store.dart:120 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S2_refute.md §7. Diag.clearCritical is called for backstop-init, backstop-schedule, backstop-exact, voice-init, audio-play and save-$key — never for a load- scope. Store.load runs once at home.dart:75, so nothing can retract it. S2's own probe measured this and filed no finding.

agent_reports/S2_refute.md § S2R-M3 — A load- critical banner can never be cleared for the life of the session: whole-tree grep — $ grep -rn "clearCritical" lib/ lib/alarm_backstop.dart:91: Diag.clearCritical('backstop-init'); lib/alarm_backstop.dart:202: Diag.clearCritical('backstop-schedule'); lib/alarm_backstop.dart:203: if (_exactOk) Diag.clearCritical('backstop-exact'); lib/diagnostics.dart:41: static void clearCritical(String scope) { lib/audio/voice.dart:57: Diag.clearCritical('voice-init'); lib/audio/audio.dart:74: if (critical) Diag.clearCritical('audio-play'); lib/engine/store.dart:139: Diag.clearCritical('save-$key'); and behaviourally: M3 after a full healthy rewrite the banner is still: {load-cadence-timers-v1}

What to fix

agent_reports/S2_refute.md § S2R-M3 — A load- critical banner can never be cleared for the life of the session: call Diag.clearCritical('load-$key') from the success path of _write alongside the existing Diag.clearCritical('save-$key') at store.dart:139 — a successful write to a key is proof that key is healthy again.

How to prove it

agent_reports/S2_refute.md § S2R-M3 — A load- critical banner can never be cleared for the life of the session: my test "M3 — a load- critical banner can never be cleared…". Invert the final expect(Diag.critical.value, contains('load-cadence-timers-v1')) to isNot(contains(…)). Red now, green after.

S2R-M4 · The `.corrupt` backup write is itself unverified and non-critical, so the "never silently lost" guarantee can fail silently

What is wrong

The .corrupt backup write is itself unverified and non-critical, so the "never silently lost" guarantee can fail silently

File and line

lib/engine/store.dart:123; the guarantee is stated at :115-118; the guard's own doc is at :173 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S2_refute.md §7. The write that delivers the guarantee uses the settings guard, documented one screen away as "comfort data — failure is logged, not critical". If it is refused, the only copy of the kitchen's configuration is gone and the failure is reported at the same level as a language preference.

agent_reports/S2_refute.md § S2R-M4 — The .corrupt backup write is itself unverified and non-critical, so the "never silently lost" guarantee can fail silently: with a platform store that refuses writes to cadence-timers-v1.corruptM4 backup present=false | scopes=[load-cadence-timers-v1, save-set-cadence-timers-v1.corrupt] | critical={load-cadence-timers-v1} save-set-cadence-timers-v1.corrupt is absent from critical.

What to fix

agent_reports/S2_refute.md § S2R-M4 — The .corrupt backup write is itself unverified and non-critical, so the "never silently lost" guarantee can fail silently: give _guard an {bool isCritical = false} parameter (S2-F10 proposes the same signature change for the one-shot flags) and pass isCritical: true for the .corrupt sibling write at store.dart:123.

How to prove it

agent_reports/S2_refute.md § S2R-M4 — The .corrupt backup write is itself unverified and non-critical, so the "never silently lost" guarantee can fail silently: my test "M4 — the .corrupt backup write is itself unverified…". Invert the last expectation to contains('save-set-cadence-timers-v1.corrupt'). Red now, green after. ---

S3-F10 + S10-F5 · All timers share one ringtone player: a ringing tone cannot be stopped, and two dishes ringing at once cannot be told apart

What is wrong

All timers share one ringtone player: a ringing tone cannot be stopped, and two dishes ringing at once cannot be told apart

File and line

lib/audio/audio.dart:19 (AudioPlayer? _ring), :62, :71 (await p.stop() before :73 play), :86-87 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · findings/S10_product.md · agent_reports/S10_refute.md · agent_reports/S3_refute.md. Found twice — S3 from the audio side, S10 from the product side. S10's refuter refuted the consequence: the two runs carry independent repeat schedules (engine.dart:283-284, :293-294, each keyed to its own RunEntry.voiceGap/nextVoiceAt), so the tones alternate, each truncated at the other's next repeat — degradation, not the total loss described. The voice channel also names each dish independently (home.dart:294-295). And the test S3 cites as proof of S3-F10 does not test it (S3R-F1).

findings/S10_product.md § S10-F5 — Two dishes ringing at once cannot be told apart by sound: one player, and the second ring stops the first: dart // lib/audio/audio.dart:62,71-73 final p = (ring ? _ring : null) ?? (_pool.isEmpty ? null : _pool[_next]); ... await p.stop(); await p.setVolume((vol * gain).clamp(0.0, 1.0)); await p.play(AssetSource('audio/$asset')); The escalation loop guarantees this collides: engine.dart:283,293-294 starts each ring's repeat gap at 7000 ms and shrinks it by ×0.72 to a 2000 ms floor, so two unattended alarms are re-triggering every two seconds each, permanently interrupting one another. findings/S3_audio_alarms.md § S3-F10 — a ringing tone cannot be stopped, and all timers share one ringtone player: lib/audio/audio.dart:62, verbatim — one _ring, selected regardless of which timer is ringing: dart final p = (ring ? _ring : null) ?? (_pool.isEmpty ? null : _pool[_next]); and lib/audio/audio.dart:86-87: dart Future<void> ringtone(String name) => _play(assetFor(name), critical: true, ring: true); Proven by test S3: the ringtone always uses the SAME player so a repeat cuts the previous one instead of layering (proof/01_findings/S3/tests/s3_audio_test.dart): three consecutive ringtone calls produce three plays on one player, while two click calls land on two distinct pooled players. grep -n "dispose\|stop()" lib/audio/audio.dart returns only p.stop() at line 71, which is the internal restart before a play; there is no external stop path.

What to fix

findings/S10_product.md § S10-F5 — Two dishes ringing at once cannot be told apart by sound: one player, and the second ring stops the first: route ringtones through the existing 4-player pool keyed by timer id rather than through one shared player, so a repeat still replaces its own previous tone but not another dish's. The pool already exists (audio.dart:12,45-47); this is plumbing, not a new capability. findings/S3_audio_alarms.md § S3-F10 — a ringing tone cannot be stopped, and all timers share one ringtone player: add Future<void> stopRing() async { await _ring?.stop(); } to SoundBox and call it from _HomeScreenState.onStopped (lib/ui/home.dart:338) alongside voice.stopFor(id), guarded by "no other timer is still ringing" so acknowledging one dish never silences another.

How to prove it

findings/S10_product.md § S10-F5 — Two dishes ringing at once cannot be told apart by sound: one player, and the second ring stops the first: a test with a fake player factory asserting that firing timer B does not call stop() on the player that is currently sounding timer A — red today, since there is only one such player. findings/S3_audio_alarms.md § S3-F10 — a ringing tone cannot be stopped, and all timers share one ringtone player: a test that starts two ringing timers, stops one, and asserts a stop reaches the ring player only when the last ringing timer is acknowledged. It cannot compile today (stopRing does not exist) and passes after.

S3-F11 · Haptics: failures escape as unhandled async errors, and `_canVibrate` does not mean "has a vibrator"

What is wrong

Haptics: failures escape as unhandled async errors, and _canVibrate does not mean "has a vibrator"

File and line

lib/audio/audio.dart:49-53, :96-101, :105-109Vibration.vibrate unawaited and uncaught at :99 and :107 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md. Confirmed by reading the block. Overlaps S5-F8's unawaited-futures inventory, which cites the same two lines.

findings/S3_audio_alarms.md § S3-F11 — haptics: failures escape as unhandled async errors, and _canVibrate does not mean "has a vibrator": lib/audio/audio.dart:96-101, verbatim: dart void _vibratePattern(List<int> pattern) { if (_canVibrate) { // Vibration pattern format: [wait, on, off, on, ...] Vibration.vibrate(pattern: [0, ...pattern]); } } Package source, vibration_platform_interface-0.1.2/lib/src/method_channel_vibration.dart:20-47: dart @override Future<bool> hasVibrator() async { try { if (Platform.isAndroid) { final deviceData = await deviceInfo.androidInfo; if (!deviceData.isPhysicalDevice) { return false; } return true; } else if (Platform.isIOS) { The unhandled error is proven by test S3: a vibration that fails natively escapes as an UNHANDLED async error — no Diag, no journal (proof/01_findings/S3/tests/s3_audio_test.dart), which catches a PlatformException in a runZonedGuarded handler and asserts Diag.log is empty.

What to fix

findings/S3_audio_alarms.md § S3-F11 — haptics: failures escape as unhandled async errors, and _canVibrate does not mean "has a vibrator": Vibration.vibrate(...).catchError((e) => Diag.fail('haptics', e)); at both :99 and :107. Replace the unreachable probe with a real capability check — Vibration.hasCustomVibrationsSupport(), which does go to the vibration method channel (method_channel_vibration.dart:98-105) — and keep the existing catch around it, which then becomes reachable.

How to prove it

findings/S3_audio_alarms.md § S3-F11 — haptics: failures escape as unhandled async errors, and _canVibrate does not mean "has a vibrator": the test named above inverts: change expect(Diag.log, isEmpty) to expect(Diag.log.map((e) => e.scope), contains('haptics')). It is green with isEmpty today (proof recorded) and red after.

S3-F12 · A wedged TTS engine costs 12 s of silence per announcement and raises no operator banner

What is wrong

A wedged TTS engine costs 12 s of silence per announcement and raises no operator banner

File and line

lib/audio/voice.dart:161-173; the 12 s timeout at :168, the non-critical voice-speak at :170 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md. Verified. Distinct from S12-F6, which is the native-side channel-result leak on the same wedge.

findings/S3_audio_alarms.md § S3-F12 — a wedged TTS engine costs 12 s of silence per announcement and raises no operator banner: lib/audio/voice.dart:169-173, verbatim: dart } on TimeoutException { Diag.fail('voice-speak', 'native speak timed out (engine wedged?)'); } catch (e) { Diag.fail('voice-speak', e); } Proven by test S3: a wedged TTS engine unjams the queue after 12 s and the NEXT announcement still goes out (proof/01_findings/S3/tests/s3_voice_test.dart), which drives a never-completing native speak under fake_async, asserts the second phrase is spoken after the timeout, and asserts Diag.critical.value is empty. Mutation proof/01_findings/S3/mutations/M4_voice_timeout.patch (12 s → 600 s) turns that test red — M4_voice_timeout.after.txt, EXIT_CODE=1 — so the timeout itself is genuinely exercised.

What to fix

findings/S3_audio_alarms.md § S3-F12 — a wedged TTS engine costs 12 s of silence per announcement and raises no operator banner: count consecutive timeouts in VoiceBox and raise Diag.fail('voice-speak', …, isCritical: true) from the second one, clearing it on the next successful utterance — the same rising/falling pattern already used for audio-play at audio.dart:74.

How to prove it

findings/S3_audio_alarms.md § S3-F12 — a wedged TTS engine costs 12 s of silence per announcement and raises no operator banner: extend the test above to two wedged utterances and assert Diag.critical.value contains voice-speak. Red today (it is asserted empty), green after.

S3-F13 · The speech queue is unbounded and its only trim path cannot be tested

What is wrong

The speech queue is unbounded and its only trim path cannot be tested

File and line

lib/audio/voice.dart:26 (_queue), :36 (staleMs as a static const), :180-188 (_dropStale) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md · findings/S12_lifecycle.md §3.4. S3's refuter verified the unbounded _queue and the untestable static const staleMs. S12 measured 5,000 pending entries after 5,000 enqueues with a dead engine and concluded bounded, not a leak — because in production the bound is maxBatch × repeats ≈ 210 entries per 20-second window. Both are right about different things: the queue enforces no bound of its own; the engine's maxBatch supplies one.

findings/S3_audio_alarms.md § S3-F13 — the speech queue is unbounded and its only trim path cannot be tested: lib/audio/voice.dart:16-22 and :36, verbatim: dart class _QueueItem { final String id; final String text; final int at; // enqueued instant — a phrase can go stale before it is spoken _QueueItem(this.id, this.text) : at = DateTime.now().millisecondsSinceEpoch; } dart static const int staleMs = 20000; Proven by tests S3: the queue is UNBOUNDED — 5000 announcements are all retained (asserts v.pending == 4999) and S3: staleMs is a compile-time constant with no injection point, so the stale-drop path cannot be tested without a 20 s real-time wait (proof/01_findings/S3/tests/s3_voice_test.dart). Line 185 — the annonce perimee ignoree journal line — is one of only three lines still uncovered in voice.dart after this stream's tests (03_lcov_with_s3.info: uncovered 140 172 185).

What to fix

findings/S3_audio_alarms.md § S3-F13 — the speech queue is unbounded and its only trim path cannot be tested: inject the clock — VoiceBox({int Function()? now}) defaulting to DateTime.now().millisecondsSinceEpoch, used by _QueueItem and _dropStale — and cap _queue at a small constant (one pending phrase per timer is the physical maximum the product needs; Engine.maxBatch is 3 and the board is one screen), dropping the oldest with a journal line when the cap is hit.

How to prove it

findings/S3_audio_alarms.md § S3-F13 — the speech queue is unbounded and its only trim path cannot be tested: a test that enqueues two phrases with an injected clock, advances it by staleMs + 1, drains, and asserts the stale phrase was never spoken and v.pending == 0. It cannot be written today.

S3-F14 + S6-F18 (title half) · A background notification cannot tell the cook which batch is ready: `_desired` uses `engine.labelFor` but `showNow` uses raw `t.name`, and `_nid` is per-clone

What is wrong

A background notification cannot tell the cook which batch is ready: _desired uses engine.labelFor but showNow uses raw t.name, and _nid is per-clone

File and line

lib/alarm_backstop.dart:113 (engine.labelFor(t.id)) vs :259 (raw t.name); consumer lib/ui/home.dart:300; also :186 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. Found twice. S6's refuter: "a backgrounded rush stacks three notifications all reading ⏰ Fries. That is a behavioural defect, not a duplication, and it does not belong at LOW inside a zero-padding finding." Take S3's MEDIUM.

findings/S3_audio_alarms.md § S3-F14 — a background notification cannot tell the cook which batch is ready: lib/alarm_backstop.dart:258-260, verbatim: dart await _plugin.show( id: _nid(t.id), title: '⏰ ${t.name}', body: body, notificationDetails: _details); Proven by test S3: a SCHEDULED backstop names the batch but showNow() does not — the background notification cannot say which batch is ready (proof/01_findings/S3/tests/s3_backstop_test.dart), which asserts the scheduled titles are ['⏰ Fries', '⏰ Fries [lot 2]'] while showNow on the same clone yields '⏰ Fries'. findings/S6_dry_deadcode.md § S6-F18 — Four independent copies of a two-digit zero-pad, and two of the alarm notification title: proof/01_findings/S6/duplication_sites.txt, D14/D16 lib/journal.dart:62: '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; lib/journal.dart:222: '${now.hour.toString().padLeft(2, '0')}h' lib/journal.dart:223: '${now.minute.toString().padLeft(2, '0')}.txt'; lib/ui/header.dart:145: Text(now.hour.toString().padLeft(2, '0'), style: style), lib/ui/header.dart:147: Text(now.minute.toString().padLeft(2, '0'), style: style), lib/ui/modals.dart:303: ? '${p[0]}:${p[1].toString().padLeft(2, '0')}' lib/ui/modals.dart:399: unit == 'min' ? '$value' : value.toString().padLeft(2, '0'), lib/ui/modals.dart:502: _numBox((s.sec % 60).toString().padLeft(2, '0'), lib/ui/modals.dart:704: String p(int n) => n.toString().padLeft(2, '0'); lib/ui/theme.dart:79: return '${v ~/ 60}:${(v % 60).toString().padLeft(2, '0')}'; lib/alarm_backstop.dart:186: title: '⏰ $name', lib/alarm_backstop.dart:259: id: _nid(t.id), title: '⏰ ${t.name}', body: body,

What to fix

CORRECTED FIX: keep the two local pad2 idioms; centralising them increases coupling and does not remove meaningful duplication. No product test is required; the row is REFUTED.

How to prove it

findings/S3_audio_alarms.md § S3-F14 — a background notification cannot tell the cook which batch is ready: the test named above changes its final expectation from '⏰ Fries' to '⏰ Fries [lot 2]'. It passes with '⏰ Fries' today (proof recorded) and fails after. findings/S6_dry_deadcode.md § S6-F18 — Four independent copies of a two-digit zero-pad, and two of the alarm notification title: extend test/backstop_test.dart with a case that spawns a clone, fires showNow for it, and asserts the mocked show call's title argument equals '⏰ Fries [lot 2]'. Today that assertion fails with '⏰ Fries'. ---

S3-F15 + S12-F5 · `SoundBox` never releases its five `AudioPlayer`s; `SoundBox` and `VoiceBox` have no disposal at all

What is wrong

SoundBox never releases its five AudioPlayers; SoundBox and VoiceBox have no disposal at all

File and line

lib/audio/audio.dart:10-116 (whole class), players created at :44-54; lib/ui/home.dart:235-243 (dispose, which does not release them) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · findings/S12_lifecycle.md · agent_reports/S12_refute.md §2. Found twice. S12's refuter reproduced it (AudioPlayer create calls during boot: 5 / calls after unmount: []) and refuted the "LEAK" label: SoundBox.init() is called once, from _boot(); five players exist for the process lifetime and never grow. That is a missing dispose, not an accumulation, and S12's "6 undisposed on at least one reachable path" overstates it.

findings/S12_lifecycle.md § S12-F5 — The five AudioPlayers are never released; SoundBox and VoiceBox have no disposal at all: recorded run proof/01_findings/S12/w5_dispose_scope.txt — the harness records every platform call, boots the real _HomeScreenState, then unmounts: === S12 W5 — platform calls made by dispose() === AudioPlayer create calls during boot: 5 calls after unmount: [] Five native players created, zero released. proof/01_findings/S12/grep_dispose_sites.txt shows no disposal site anywhere under lib/audio/. findings/S3_audio_alarms.md § S3-F15 — SoundBox never releases its five players, and that leak is observable: measured, not asserted. While building proof/01_findings/S3/tests/s3_volume_channel_test.dart, a widget test that let the app actually play a WAV failed at teardown with: ══╡ EXCEPTION CAUGHT BY SCHEDULER LIBRARY ╞═══ An animation is still running even after the widget tree was disposed. There was one transient callback left. ... #3 FramePositionUpdater._tick (package:audioplayers/src/position_updater.dart:75:52) The test only passes because the fake platform now emits an AudioEventType.complete event to stop the updater — see the comment on _FakePlatform.resume in that file. On a device nothing emits that for a player the app never stops. grep -n "dispose\|release" lib/audio/audio.dart returns no match.

What to fix

findings/S12_lifecycle.md § S12-F5 — The five AudioPlayers are never released; SoundBox and VoiceBox have no disposal at all: add Future<void> dispose() to SoundBox (for (final p in _pool) await p.dispose(); await _ring?.dispose();) and to VoiceBox (_queue.clear(); _ch.invokeMethod('stop');), and call both from _HomeScreenState.dispose(). findings/S3_audio_alarms.md § S3-F15 — SoundBox never releases its five players, and that leak is observable: add Future<void> dispose() async { for (final p in _pool) { await p.dispose(); } await _ring?.dispose(); _pool.clear(); _ring = null; } to SoundBox, and call it — plus a matching teardown on VoiceBox, which also has none — from _HomeScreenState.dispose() at lib/ui/home.dart:236-243.

How to prove it

findings/S12_lifecycle.md § S12-F5 — The five AudioPlayers are never released; SoundBox and VoiceBox have no disposal at all: proof/01_findings/S12/tests/s12_w5_dispose_scope_test.dart. Invert its two expectations to isNotEmpty — red now, green after. --- findings/S3_audio_alarms.md § S3-F15 — SoundBox never releases its five players, and that leak is observable: a widget test that pumps HomeScreen, lets a restored overdue timer ring, then pumps const SizedBox() and asserts no scheduler exception at teardown. It is red today — the exception above is the recorded failure.

S3-F16 · iOS `speak` can complete the wrong utterance's result, breaking one-announcement-at-a-time

What is wrong

iOS speak can complete the wrong utterance's result, breaking one-announcement-at-a-time

File and line

ios/Runner/AppDelegate.swift:157-175 and :186-192 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md §Part 5. The refuter re-derived the ordering argument from the source and confirmed it, and separately discarded a pendingSpeaks key-collision hazard as unreachable. No Xcode on this machine — neither agent compiled or ran this.

findings/S3_audio_alarms.md § S3-F16 — iOS speak can complete the wrong utterance's result, breaking one-announcement-at-a-time: ios/Runner/AppDelegate.swift:161-174, verbatim: swift if synth.isSpeaking { synth.stopSpeaking(at: .immediate) completeAllSpeaks() } let utterance = AVSpeechUtterance(string: text) utterance.volume = min(max(volume, 0), 1) utterance.rate = rate if let id = voiceId, let v = AVSpeechSynthesisVoice(identifier: id) { utterance.voice = v } else { utterance.voice = AVSpeechSynthesisVoice(language: locale) } pendingSpeaks[ObjectIdentifier(utterance)] = result synth.speak(utterance) and :186-192: swift fileprivate func completeAllSpeaks() { DispatchQueue.main.async { let pending = self.pendingSpeaks self.pendingSpeaks.removeAll() pending.values.forEach { $0(false) } } }

What to fix

findings/S3_audio_alarms.md § S3-F16 — iOS speak can complete the wrong utterance's result, breaking one-announcement-at-a-time: capture the results to complete before starting the new utterance, and complete them synchronously on the current (main) thread — replace lines 161-164 with: swift if synth.isSpeaking { let stale = pendingSpeaks pendingSpeaks.removeAll() synth.stopSpeaking(at: .immediate) stale.values.forEach { $0(false) } } leaving completeAllSpeaks() for the stop handler only. This also matches the Kotlin structure exactly, which is the stated rule (AppDelegate.swift:5-10).

How to prove it

findings/S3_audio_alarms.md § S3-F16 — iOS speak can complete the wrong utterance's result, breaking one-announcement-at-a-time: device protocol D5 below. There is no host-side proof for Swift; the claim above is a reading of the source and is labelled as such.

S3R-F4 · S3's four mutation proofs do not meet R8 as written

What is wrong

S3's four mutation proofs do not meet R8 as written

File and line

proof/01_findings/S3/mutations/M1_volume_floor.after.txt, M2_backstop_chain.after.txt, M3_audio_critical.after.txt, M4_voice_timeout.after.txt at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S3_refute.md §4. R8(c) and R8(d) are met; (a) and (b) are not — none of the four runs used --reporter=json, and all four were scoped with --plain-name to a single file, so the failing set was never compared against the whole suite. See EVIDENCE_INTEGRITY.md.

agent_reports/S3_refute.md § S3R-F4 — S3's four mutation proofs do not meet R8 as written: the four recorded command lines, verbatim from the file headers: COMMAND: flutter test test/volume_test.dart --plain-name plancher COMMAND: flutter test test/s3_backstop_test.dart --plain-name 'chained timer' COMMAND: flutter test test/s3_audio_test.dart --plain-name 'ringing before init' COMMAND: flutter test test/s3_voice_test.dart --plain-name 'wedged TTS' M1's run additionally shows +5 -1 — five other tests inside the same --plain-name group ran, so even within that file the failing set was not one.

What to fix

agent_reports/S3_refute.md § S3R-F4 — S3's four mutation proofs do not meet R8 as written: replace the four after.txt files with whole-suite --reporter=json runs from proof/01_findings/S3_refute/mutations/, which already cover M2 (B5), M3 (A1) and M4 (V4). M1's target lives in test/volume_test.dart, which is not S3's file; it is covered by the existing suite and does not need re-proving to land S3's tests.

How to prove it

agent_reports/S3_refute.md § S3R-F4 — S3's four mutation proofs do not meet R8 as written: mutation_harness.py re-run; the summary JSON is the artefact.

S3R-F5 · R8(b) as written can never be satisfied by a `testWidgets` test, which puts 7 of S3's 40 tests permanently outside the rule

What is wrong

R8(b) as written can never be satisfied by a testWidgets test, which puts 7 of S3's 40 tests permanently outside the rule

File and line

AGENT_RULES.md R8 clause (b); affected artefact proof/01_findings/S3/tests/s3_volume_channel_test.dart (all 7 tests are testWidgets) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S3_refute.md §4. flutter_test catches a TestFailure inside testWidgets through the Flutter error pipeline and re-reports it as a plain error, so the JSON reporter emits result: "error" with isFailure: false. This is a defect in the audit rule, not in the app — Phase 4 must amend R8(b) to accept isFailure: false widget-test errors, or it will reject valid mutation proofs.

agent_reports/S3_refute.md § S3R-F5 — R8(b) as written can never be satisfied by a testWidgets test, which puts 7 of S3's 40 tests permanently outside the rule: three mutations, H1/H2/H3, each producing a failing set of exactly the named test with result: "error", isFailure: false, and each carrying in the same raw stream the real TestFailure with its expectation and source line: ══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞═══ The following TestFailure was thrown running a test: Expected: empty Actual: [0.4] #4 main.<anonymous closure> (file:///…/test/s3_volume_channel_test.dart:230:5) (proof/01_findings/S3_refute/mutations/raw_json/H1.json, and H2.json, H3.json.) Contrast the genuine load failure the rule is aimed at, captured by accident in the first A2 run: Failed to load "…/test/engine_test.dart": Shell subprocess crashed with SIGTERM (-15).

What to fix

agent_reports/S3_refute.md § S3R-F5 — R8(b) as written can never be satisfied by a testWidgets test, which puts 7 of S3's 40 tests permanently outside the rule: restate R8(b) as "the mutated test must have STARTED (a testStart event exists for it) and its failure must carry a TestFailure; a loading … pseudo-test in the failing set disqualifies the mutation." That is checkable from the same JSON and captures the real intent.

How to prove it

agent_reports/S3_refute.md § S3R-F5 — R8(b) as written can never be satisfied by a testWidgets test, which puts 7 of S3's 40 tests permanently outside the rule: the stored raw_json/ files are the fixture: under the restated rule H1/H2/H3 pass and A2's flaked first run still fails. ---

S4-F01 · The ±10 s / ✕ control row overflows its tile, and at dense boards the stop button is clipped away

What is wrong

The ±10 s / ✕ control row overflows its tile, and at dense boards the stop button is clipped away

File and line

lib/ui/tile.dart:600 (the Row), sized by :573-598, hit padding :677-678 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md · proof/01_findings/S4/. No agent_reports/S4_refute.md at time of writing.

findings/S4_ui.md § S4-F01 — The ±10 s / ✕ control row overflows its tile, and at dense boards the stop button is clipped away: Flutter's own assertion names the widget and the line (proof/01_findings/S4/rebuild_cost.txt:65-77): A RenderFlex overflowed by 8.5 pixels on the right. The relevant error-causing widget was: Row Row:file://a scratch working copy creator: Row ← Center ← Expanded ← Column ← Padding ← Opacity ← Positioned ← Stack ← … Measured clipping of the stop button, proof/01_findings/S4/touch_targets.txt: TOUCH 1366x1024 n=12 tile=325.0x276.0 | X 62.5x56.0 CLIPPED_BY_17.2px TOUCH 960x600 n=30 tile=153.0x100.0 | X 35.9x35.5 CLIPPED_BY_27.9px TOUCH 600x960 n=12 tile=189.0x161.0 | X 40.3x52.0 CLIPPED_BY_12.5px TOUCH 1280x800 n=100 tile= 94.0x 80.0 | X 24.7x27.5 CLIPPED_BY_20.1px And the consequence, proof/01_findings/S4/a11y_hit_test.txt: HITTEST n=100 btn=24.7x27.5 visibleWidth=4.6 tapDelivered=true stopWorked=false i.e. at 100 timers only 4.6 px of the stop button survives the clip and a tap on that remnant does not stop the timer. Visual proof: proof/01_findings/S4/shots/board_1280x800_n100_running.png [not published] (every tile striped by the overflow indicator, no ✕ visible) and …_n30_running.png.

What to fix

findings/S4_ui.md § S4-F01 — The ±10 s / ✕ control row overflows its tile, and at dense boards the stop button is clipped away: make the row's width budget explicit instead of implicit. In _controls (lib/ui/tile.dart:568) compute avail = w - 6 * cw - 20 (tile padding + the fixed hit paddings), choose the size tier from avail rather than from w, drop minW to 0 when 2 * minW + gap + stopGap + stopWidth > avail, and wrap the finished row in FittedBox(fit: BoxFit.scaleDown) as a last-resort guard so no tile geometry can ever push a control outside the card. No new behaviour, no new control.

How to prove it

findings/S4_ui.md § S4-F01 — The ±10 s / ✕ control row overflows its tile, and at dense boards the stop button is clipped away: tile_controls_never_overflow — for every (surface, n) pair in {1280×800, 1366×1024, 960×600, 600×960, 360×640} × {1, 2, 4, 12, 30, 100} with all timers running, assert tester.takeException() is null and that the hit rectangle of , +10 and −10 is fully inside tester.getRect(find.byType(TileView).at(i)). Red now (19/30 configurations clip), green after. ---

S4-F02 · A dish name shrinks without any floor: 24 characters render at 7.5 px on a 30-tile board

What is wrong

A dish name shrinks without any floor: 24 characters render at 7.5 px on a 30-tile board

File and line

lib/ui/tile.dart:393-415 (FlexibleFittedBox(fit: BoxFit.scaleDown)Text) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md · proof/01_findings/S4/. Cross-reference: S10's refuter (M3) found the complementary defect — the name is silently capped at 24 characters with the counter blanked.

findings/S4_ui.md § S4-F02 — A dish name shrinks without any floor: 24 characters render at 7.5 px on a 30-tile board: proof/01_findings/S4/layout_stress.txt (effective font = declared size × the measured FittedBox scale): NAME 1280x800 | n=12 | normal | tile=305x230 | declaredFont=32.20 | scale=1.000 | EFFECTIVE_FONT_PX=32.20 NAME 1280x800 | n=12 | long-24 | tile=305x230 | declaredFont=32.20 | scale=0.350 | EFFECTIVE_FONT_PX=11.27 NAME 1280x800 | n=30 | long-24 | tile=204x138 | declaredFont=19.32 | scale=0.390 | EFFECTIVE_FONT_PX=7.54 NAME 1280x800 | n=30 | unbroken-60 | tile=204x138 | declaredFont=19.32 | scale=0.156 | EFFECTIVE_FONT_PX=3.02 NAME 600x960 | n=30 | unbroken-60 | tile=143x106 | declaredFont=14.84 | scale=0.142 | EFFECTIVE_FONT_PX=2.11 On the same 30-tile board the countdown digits render at 30.4 px, so the dish name is drawn at 25 % of the height of its own countdown — the one piece of text that says which pan this is is the smallest thing on the tile. Visual: proof/01_findings/S4/shots/board_1280x800_n12_longname.png [not published].

What to fix

findings/S4_ui.md § S4-F02 — A dish name shrinks without any floor: 24 characters render at 7.5 px on a 30-tile board: floor the shrink and let the tail go. Replace the bare FittedBox with a two-stage rule: scale down to a floor of max(9.0, 8 * ch) px, and once the floor is reached switch the Text to overflow: TextOverflow.ellipsis (it already has maxLines: 1, softWrap: false). A truncated name read at a glance beats a complete name nobody can read. If Serge prefers, the second line of the name can wrap instead (maxLines: 2) — but the floor is the fix.

How to prove it

findings/S4_ui.md § S4-F02 — A dish name shrinks without any floor: 24 characters render at 7.5 px on a 30-tile board: dish_name_never_renders_below_floor — for names of 5/24/60/120 characters on {1280×800, 600×960} × {1, 12, 30, 100}, compute declaredFont × fittedScale exactly as the instrument does and assert >= 9.0. Red now (measured 2.11 px worst case), green after. ---

S4-F03 · 16 interactive elements are below the 48 dp minimum, including every control used during service

What is wrong

16 interactive elements are below the 48 dp minimum, including every control used during service

File and line

lib/ui/tile.dart:431-435 (×N chip), :573-598 + :677-678 (± and ✕) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md · proof/01_findings/S4/. Corroborated from another angle by S10-F13, whose refuter re-derived the geometry independently and found S4/S10 both understated it: at tile 127×108 the ±10 s button measures 35 px and the ✕ 31.6 px including hit padding.

findings/S4_ui.md § S4-F03 — 16 interactive elements are below the 48 dp minimum, including every control used during service: proof/01_findings/S4/touch_targets.txt, verbatim: HEADER 1280x800 | ⚙ 58.2x40.0 | ✎ EDIT 143.6x38.0 | + NEW 126.0x38.0 HEADER 600x960 | ⚙ 44.2x37.0 | ✎ 41.8x34.0 | + 41.8x34.0 HEADER 320x480 | ⚙ 35.3x33.0 | ✎ 33.3x31.0 | + 33.3x31.0 TOUCH 1280x800 n=30 | +10 73.0x49.2 | -10 68.0x49.2 | X 40.8x49.2 | dup 55.5x40.0 TOUCH 1280x800 n=100 | +10 43.0x28.2 | -10 38.0x28.2 | X 24.7x27.5 | dup 55.5x40.0 MODAL EDITOR chain=false | ▲ 52.0x40.0 | ▼ 52.0x40.0 | 🗑 42.6x58.0 | Single 250.0x42.0 | Chirp 112.5x42.0 | 0:30 86.4x32.0 MODAL EDITOR chain=true | ✕ 29.1x36.0 | + Add step 508.0x43.0 MODAL SETTINGS | English 250.0x42.0 | CLOSE 508.0x58.0 | slider 508.0x48.0 Only Cancel/Save/Close/Send the log (58.0 tall) and the volume slider (48.0) meet the minimum.

What to fix

findings/S4_ui.md § S4-F03 — 16 interactive elements are below the 48 dp minimum, including every control used during service: one shared helper, not 16 edits. Add Widget tapTarget({required Widget child, double min = 48}) to a new lib/ui/hit.dart that wraps its child in a transparent ConstrainedBox(minWidth: min, minHeight: min) + Center, and route every GestureDetector in tile.dart, header.dart and modals.dart through it. Visual size does not change — only the invisible hit area grows, which is why this is a defect repair and not a redesign. Where a tile is too small to give 48 dp (n ≥ 100 on a 10-inch tablet), the tile itself should absorb the target.

How to prove it

findings/S4_ui.md § S4-F03 — 16 interactive elements are below the 48 dp minimum, including every control used during service: every_interactive_element_meets_48dp — walk the widget tree for GestureDetector/InkWell/Slider on the home board (n ∈ {1, 12, 30}), the editor, and Settings, and assert size.width >= 48 && size.height >= 48 for each. Red now (16 element types fail), green after. ---

S4-F05 + S12-F7 · The 150 ms tick rebuilds the entire screen unconditionally, including when nothing is running; one tile's countdown rebuilds every tile

What is wrong

The 150 ms tick rebuilds the entire screen unconditionally, including when nothing is running; one tile's countdown rebuilds every tile

File and line

lib/ui/home.dart:154-167, specifically the unguarded setState(() {}) at :166; :559 (now: DateTime.now() handed to every tile) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md · findings/S12_lifecycle.md · agent_reports/S12_refute.md §3.1. Found twice. S12's refuter reproduced 155 builds/tick, and measured it reaching layout (16 marks) and paint (2 marks) — net 1,766 µs/tick falling to 7.5 µs with the one-line guard if (mounted && engine.run.isNotEmpty) setState(() {}), a 235× reduction. HIGH is not supported: 1,766 µs is debug-JIT Dart with asserts on, on Apple Silicon, not release AOT on an ARM tablet, and there is no power measurement. The settling artefact is named: adb shell dumpsys batterystats before and after, one hour foreground, unplugged, report the delta in mAh.

findings/S12_lifecycle.md § S12-F7 — The 150 ms tick rebuilds the entire screen unconditionally, including when nothing is running: recorded run proof/01_findings/S12/w1_idle_tick_rebuild_cost.txt — the real _HomeScreenState, booted, seven idle tiles, debugPrintRebuildDirtyWidgets on for exactly one 150 ms tick: === S12 W1 — ONE 150 ms tick, 0 timers running, 7 idle tiles === widgets rebuilt by that single tick: 155 TileView: 7 AnimatedBuilder: 7 Text: 29 Container: 19 CustomPaint: 0 at 150 ms that is 1033 widget builds per second, 6.67 frames/s, sustained for the whole service --- first 20 rebuild lines --- Building HomeScreen(dirty, state: _HomeScreenState#d337c) Building [root widget line omitted] ... Building Header(dependencies: [MediaQuery]) The engine itself is not the cost: 288,001 ticks of Engine.tick() over a 12-hour service took 177 ms total, 0.6 µs per tick (proof/01_findings/S12/soak_engine_journal.txt). All of the work is the rebuild. findings/S4_ui.md § S4-F05 — One tile's countdown rebuilds every tile, and the whole screen is a single repaint layer: proof/01_findings/S4/greps.txtgrep -rn "RepaintBoundary\|Semantics\|semanticLabel\| excludeSemantics\|MergeSemantics\|tooltip" lib/exit=1, no output. Measured rebuild scope for one running timer among N (proof/01_findings/S4/rebuild_cost.txt): REBUILD n=1 runningTimers=1 rebuiltWidgets=89 TileView:1 REBUILD n=12 runningTimers=1 rebuiltWidgets=243 TileView:12 REBUILD n=30 runningTimers=1 rebuiltWidgets=495 TileView:30 REBUILD n=100 runningTimers=1 rebuiltWidgets=1475 TileView:100 IDLE 12 idle timers, 0 running: rebuiltWidgets=221 (TileView:12, Header:1, _Clock:1, CadenceMark:1, Image:1) TREE n=30 elements=3305 renderObjects=2059 repaintBoundaries=2 customPaints=30 TREE boundary: RepaintBoundary ← _FocusInheritedScope ← Semantics ← _FocusScopeWithExternalFocusNode ← … So: 30 tiles rebuild when one countdown moves; ~14.2 widgets of rebuild per extra tile; and a board with nothing running at all still rebuilds 221 widgets every 150 ms — 1,473 widget builds per second at 12 idle tiles, 9,833 per second at 100 tiles with one timer going.

What to fix

findings/S12_lifecycle.md § S12-F7 — The 150 ms tick rebuilds the entire screen unconditionally, including when nothing is running: two changes, neither a new feature. (a) Skip the rebuild when nothing can have changed: keep calling engine.tick() (it must still catch a restored overdue timer) but only setState when engine.run.isNotEmpty, plus once per second for the header clock. (b) Wrap each TileView in a RepaintBoundary in _buildTile (lib/ui/home.dart:627) so a tile that did not change does not repaint. findings/S4_ui.md § S4-F05 — One tile's countdown rebuilds every tile, and the whole screen is a single repaint layer: three surgical changes, no new behaviour. (1) Wrap each positioned tile in RepaintBoundary at lib/ui/home.dart:594-600 so a tile's pie repaint stops at its own layer. (2) Give the header its own ValueListenable<DateTime> (or a small StatefulWidget with its own 500 ms ticker) instead of receiving DateTime.now() from the board rebuild at :559 — the clock only changes twice a second and the logo Image never changes. (3) Skip the setState when nothing on screen would differ: the ticker already knows whether any run entry is running/ringing; when the map is empty of both, do not call setState.

How to prove it

findings/S12_lifecycle.md § S12-F7 — The 150 ms tick rebuilds the entire screen unconditionally, including when nothing is running: proof/01_findings/S12/tests/s12_w1_idle_tick_cost_test.dart. Change its assertion from expect(built, greaterThan(0)) to expect(built, lessThan(20)) — red now at 155, green after. --- findings/S4_ui.md § S4-F05 — One tile's countdown rebuilds every tile, and the whole screen is a single repaint layer: one_running_timer_rebuilds_only_its_own_tile — install debugOnRebuildDirtyWidget, drive one tick on a 30-tile board with a single running timer, and assert TileView rebuild count == 1 and total rebuilt widgets < 60. Red now (30 and 495), green after. Second test idle_board_does_not_rebuild: 12 idle timers, one tick, assert 0 rebuilds. ---

S4-F06 · The urgency colour is isoluminant across its first half and non-monotonic overall

What is wrong

The urgency colour is isoluminant across its first half and non-monotonic overall

File and line

lib/ui/theme.dart:37-39 (anchors), :59-69 (fillFor) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md · proof/01_findings/S4/. S7's refuter independently verified the anchor values while auditing the theme_test.dart spec: fillFor(1.0) is 0xFF5CC79A (mint anchor) and fillFor(0.0) is 0xFFEC6A6A (red anchor), and both fillFor(0.35) and fillFor(0.15) return their anchors exactly because they mix with t = 0 at :66-67.

findings/S4_ui.md § S4-F06 — The urgency colour is isoluminant across its first half and non-monotonic overall: proof/01_findings/S4/contrast.txt: RAMP p=1.00 #5CC79A L=0.4546 RAMP p=0.35 #EDB24E L=0.5040 RAMP p=0.60 #B5BA6B L=0.4600 RAMP p=0.15 #EC6A6A L=0.2918 RAMP mint-vs-amber contrast=1.098:1 RAMP amber-vs-red contrast=1.621:1 RAMP mint-vs-red contrast=1.476:1 RAMP mint-vs-track contrast=1.561:1 RAMP amber-vs-track contrast=1.422:1 The wedge boundary itself — the moving edge that shows how much time is left — separates from the track by only 1.422:1 at amber.

What to fix

findings/S4_ui.md § S4-F06 — The urgency colour is isoluminant across its first half and non-monotonic overall: make the ramp monotonic in lightness while keeping Serge's hues. Darken the amber anchor and lighten the mint anchor so that L(mint) ≈ 0.62, L(amber) ≈ 0.46, L(red) ≈ 0.29 — a ~1.5:1 step between consecutive states, which survives both colour deficiency and glare. This is a change of three numbers at lib/ui/theme.dart:37-39, not a redesign, and the wedge geometry is untouched.

How to prove it

findings/S4_ui.md § S4-F06 — The urgency colour is isoluminant across its first half and non-monotonic overall: urgency_ramp_is_monotonic_and_separable — compute WCAG relative luminance of fillFor(p) for p from 1.0 down to 0.0 in steps of 0.05 and assert (a) the sequence is non-increasing, and (b) ratio(fillFor(1.0), fillFor(0.35)) >= 1.4 and ratio(fillFor(0.35), fillFor(0.0)) >= 1.4. Red now (1.098:1 and a rising segment), green after. ---

S4-F07 · Five text/background pairs fail WCAG contrast, including the multi-step phase banner

What is wrong

Five text/background pairs fail WCAG contrast, including the multi-step phase banner

File and line

lib/ui/tile.dart:207 (C.muted idle countdown), :506 + :536 (banner on C.amber) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md · proof/01_findings/S4/. —

findings/S4_ui.md § S4-F07 — Five text/background pairs fail WCAG contrast, including the multi-step phase banner: proof/01_findings/S4/contrast.txt (47 pairs computed from the live palette; failures): CONTRAST 2.67:1 | FAIL | tile idle: countdown C.muted on C.tileIdle | fg=#828A80 bg=#E7DED0 CONTRAST 2.43:1 | FAIL | chain banner: C.onAccent on C.amber | fg=#FFF8F0 bg=#DD9207 CONTRAST 2.43:1 | FAIL | edit badge: C.onAccent on C.amber CONTRAST 2.43:1 | FAIL | header btn active: C.onAccent on C.amber CONTRAST 2.43:1 | FAIL | modal chip selected: C.onAccent on C.amber CONTRAST 1.26:1 | FAIL | banner step chip: white .24 on C.amber (the "2/3" step counter) CONTRAST 3.88:1 | AA-large-only | tile ringing: digits C.red on C.ringInnerTop For orientation, the passing anchors are strong: 12.23:1 for tile digits on an idle tile, 17.38:1 for the header wordmark, 15.37:1 for button ink on paper, and 5.31–8.60:1 for the countdown over every point of the urgency ramp. The palette is good; five specific pairs are not.

What to fix

findings/S4_ui.md § S4-F07 — Five text/background pairs fail WCAG contrast, including the multi-step phase banner: darken C.amber for text-bearing surfaces or switch the ink to C.text. Using C.text (#1D211E) on C.amber gives 8.60:1 and needs a one-word change at lib/ui/tile.dart:536, :559, lib/ui/header.dart:185, lib/ui/modals.dart:110. For the idle countdown, replace C.muted with a 4.5:1-compliant grey (e.g. #5F665D on #E7DED0 → 5.6:1) at lib/ui/tile.dart:207.

How to prove it

findings/S4_ui.md § S4-F07 — Five text/background pairs fail WCAG contrast, including the multi-step phase banner: palette_pairs_meet_wcag — assert ratio(fg,bg) >= 4.5 for the idle countdown, banner text, badge text, active header button and selected chip, and >= 3.0 for the ringing digits. Red now (2.43–3.88:1), green after. ---

S4-F08 · The app is invisible to a screen reader: zero semantic labels, zero button roles, and the LCD ghost digits are announced

What is wrong

The app is invisible to a screen reader: zero semantic labels, zero button roles, and the LCD ghost digits are announced

File and line

Whole UI layer — grep -rn 'Semantics\|semanticLabel\|ExcludeSemantics\|tooltip' lib/ returns zero matches across 4,891 lines at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md · research/01_prior_work.md §2.1 A1-6, §2.4. This is the largest single decision the Flutter line did not carry over from the v2 rewrite. Prior audit A1-6 (2026-07-19) filed it as MEDIUM and it is recorded STILL OPEN; A4 §14-15 documents the v2 work (real buttons, native dialogs, explicit move-earlier/move-later reorder controls) that was dropped. Same defect, third independent sighting.

findings/S4_ui.md § S4-F08 — The app is invisible to a screen reader: zero semantic labels, zero button roles, and the LCD ghost digits are announced: proof/01_findings/S4/a11y_hit_test.txt, a two-tile board with semantics enabled: SEM nodes=16 isButtonFlag=0 withLabel=12 withTapAction=9 SEM labels=12 | 38 | CADENCE — Kitchen Timer | ⚙ | ✎ EDIT | + NEW | FRIES 8:88 3:20 | #2 | + 10 | − 10 | ✕ | STEAK 88:88 10:00 Nine nodes accept a tap; none is flagged as a button.

What to fix

findings/S4_ui.md § S4-F08 — The app is invisible to a screen reader: zero semantic labels, zero button roles, and the LCD ghost digits are announced: four small edits, no new features. (1) ExcludeSemantics around the ghost Text (lib/ui/tile.dart:485). (2) MergeSemantics + Semantics(label: '<name>, <time> remaining', button: true) around the tile's outer GestureDetector (lib/ui/tile.dart:372). (3) Semantics(button: true, label: …) on _CtlBtn (lib/ui/tile.dart:679) with i18n labels "add ten seconds" / "remove ten seconds" / "stop", and on _HBtn (lib/ui/header.dart:187). (4) Semantics(label: …) on the clock. The i18n table gains four keys per language.

How to prove it

findings/S4_ui.md § S4-F08 — The app is invisible to a screen reader: zero semantic labels, zero button roles, and the LCD ghost digits are announced: board_is_screen_reader_usable — with tester.ensureSemantics(), assert the ghost text contributes no semantics node, that every tap-accepting node carries SemanticsFlag.isButton, and that a running tile's merged label matches RegExp(r'^\w+.*\d+:\d\d'). Red now (isButtonFlag=0, ghost present), green after. ---

S4-F09 · `MediaQuery.withNoTextScaling` is a total opt-out, and it is right for the board and wrong for the dialogs

What is wrong

MediaQuery.withNoTextScaling is a total opt-out, and it is right for the board and wrong for the dialogs

File and line

lib/main.dart:48 (owned by S14, cited not judged), consumed by every text widget at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md. S7's refuter independently verified the line while auditing the main_test.dart spec: main.dart:48 is builder: (context, child) => MediaQuery.withNoTextScaling(child: child!).

findings/S4_ui.md § S4-F09 — MediaQuery.withNoTextScaling is a total opt-out, and it is right for the board and wrong for the dialogs: proof/01_findings/S4/a11y_hit_test.txt: SCALE=1.0 wrapper=true nameBox=204.8x32.0 overflowErrors=12 SCALE=1.3 wrapper=false nameBox=260.5x41.0 overflowErrors=12 SCALE=2.0 wrapper=true nameBox=204.8x32.0 overflowErrors=12 SCALE=2.0 wrapper=false nameBox=387.6x63.0 overflowErrors=24

What to fix

findings/S4_ui.md § S4-F09 — MediaQuery.withNoTextScaling is a total opt-out, and it is right for the board and wrong for the dialogs: keep withNoTextScaling where it belongs and stop applying it globally. Move it from the MaterialApp.builder to the board subtree only — wrap the Column in HomeScreen.build (lib/ui/home.dart:555) — and let showTimerEditor/showSettings inherit the real MediaQuery.textScaler, clamped: TextScaler.linear(scale.clamp(1.0, 1.6)). The dialogs already scroll, so nothing overflows.

How to prove it

findings/S4_ui.md § S4-F09 — MediaQuery.withNoTextScaling is a total opt-out, and it is right for the board and wrong for the dialogs: dialogs_honour_os_text_scaling — open the editor at TextScaler.linear(1.6) on a 600×960 surface and assert the rendered height of the Name field label is ≥ 1.5× its height at 1.0, with tester.takeException() null. Red now (identical heights), green after. A second test keeps the board pinned: board_ignores_os_text_scaling.

S4-F10 · A failed save is reported to the log and to nobody else; the branch that would warn is unreachable

What is wrong

A failed save is reported to the log and to nobody else; the branch that would warn is unreachable

File and line

lib/ui/home.dart:443-450 (the failed-save path), :679-680 (the banner mapping) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md. Same unreachable-banner mechanism as S5-F5 and S3R-F3, both of which were independently confirmed. Fix all three with the one else branch in _criticalBanner.

findings/S4_ui.md § S4-F10 — A failed save is reported to the log and to nobody else; the branch that would warn is unreachable: proof/01_findings/S4/greps.txt: ### grep -rn Diag.fail lib/ui/home.dart 139: Diag.fail('audio-init', e, isCritical: true); 212: Diag.fail('volume-set', e); 228: Diag.fail('volume-channel', e); 446: Diag.fail('save-def', and lib/diagnostics.dart:32-34: dart if (isCritical && !critical.value.contains(scope)) { critical.value = {...critical.value, scope}; }

What to fix

findings/S4_ui.md § S4-F10 — A failed save is reported to the log and to nobody else; the branch that would warn is unreachable: add isCritical: true at lib/ui/home.dart:446. One argument. While there, call Diag.clearCritical('save-def') after the next successful save so the banner clears (mirroring lib/audio/audio.dart:74).

How to prove it

findings/S4_ui.md § S4-F10 — A failed save is reported to the log and to nobody else; the branch that would warn is unreachable: failed_save_raises_the_operator_banner — drive _openEditor with an engine whose saveDef returns false (delete the definition from engine.timers before the dialog resolves) and assert a Text containing the saveFail string is on screen. Red now, green after.

S4-F11 · Every icon in the product is a text glyph that no bundled font contains

What is wrong

Every icon in the product is a text glyph that no bundled font contains

File and line

lib/ui/tile.dart:619 (), :552 (✎ EDIT), lib/ui/header.dart:97 () at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md. Interacts with S6-F22 (uses-material-design: true ships an icon font for zero icons), which its refuter confirmed: grep -rn "Icon(\|Icons\.\|IconData\|IconButton" lib/ → zero hits.

findings/S4_ui.md § S4-F11 — Every icon in the product is a text glyph that no bundled font contains: direct cmap inspection of the shipped fonts, proof/01_findings/S4/font_glyph_coverage.txt: BigShouldersDisplay-Bold.ttf GLYPHS: 718 MISSING: ✕ ✎ ⚙ ◷ + ⚠ 🗑 🇫 ✓ BigShouldersDisplay-ExtraBold.ttf GLYPHS: 718 MISSING: ✕ ✎ ⚙ ◷ + ⚠ 🗑 🇫 ✓ BigShouldersDisplay-Medium.ttf GLYPHS: 718 MISSING: ✕ ✎ ⚙ ◷ + ⚠ 🗑 🇫 ✓ ChivoMono-Bold.ttf GLYPHS: 642 MISSING: ✕ ✎ ⚙ ◷ ▲ ▼ + ⚠ 🗑 🇫 ✓ ChivoMono-Medium.ttf GLYPHS: 642 MISSING: ✕ ✎ ⚙ ◷ ▲ ▼ + ⚠ 🗑 🇫 ✓ ChivoMono-Regular.ttf GLYPHS: 642 MISSING: ✕ ✎ ⚙ ◷ ▲ ▼ + ⚠ 🗑 🇫 ✓ DSEG7Classic-Bold.ttf GLYPHS: 75 MISSING: ✕ ✎ ⚙ ◷ ▲ ▼ + − · ⚠ 🗑 🇫 ✓ — … é ç à (/ are safe: the steppers at lib/ui/modals.dart:442-455 set no fontFamily, so they inherit ThemeData.fontFamily = Big Shoulders, which has them. Every other glyph above is drawn in a family that lacks it.) Rendered proof: proof/01_findings/S4/shots/board_1280x800_n12_running.png [not published] — with only the app's own fonts loaded, the stop button and the settings gear draw as empty boxes.

What to fix

findings/S4_ui.md § S4-F11 — Every icon in the product is a text glyph that no bundled font contains: stop drawing controls with text. Replace the six control glyphs with vector icons — either Material Icons.close / Icons.settings / Icons.edit / Icons.add / Icons.delete (already bundled with the Flutter engine's MaterialIcons font, zero new assets) or six inline CustomPainter shapes. Keep the ✎/+/✓ prefixes out of the i18n strings so the label table holds words only.

How to prove it

findings/S4_ui.md § S4-F11 — Every icon in the product is a text glyph that no bundled font contains: no_control_relies_on_a_fallback_font — a test that loads only assets/fonts/*.ttf, renders the board and both dialogs, and asserts that no Text widget in the tree contains a code point outside the union of the bundled cmaps. Red now (9 glyphs), green after. ---

S4-F12 · The operator's only failure signal is 13.5 px tall, never clears, and is placed where it steals grid height

What is wrong

The operator's only failure signal is 13.5 px tall, never clears, and is placed where it steals grid height

File and line

lib/ui/home.dart:690-702 (banner box and text style), :565 (position in the column) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md. The "never clears" half is independently confirmed for one scope class by S2R-M3 (a load- critical banner can never be cleared for the life of the session) and by S14's refuter for the wakelock banner (S14-F4).

findings/S4_ui.md § S4-F12 — The operator's only failure signal is 13.5 px tall, never clears, and is placed where it steals grid height: measured banner geometry and style with one critical scope active (proof/01_findings/S4/boot_hang.txt and the earlier probe run): BANNER visible=true text=⚠️ Sound is not working on this tablet BANNER rect=Rect.fromLTRB(14.0, 67.0, 1266.0, 86.0) BANNER fontSize=13.5 color=Color(alpha: 1.0000, red: 1.0000, green: 1.0000, blue: 1.0000, …) Contrast is fine (5.52:1, proof/01_findings/S4/contrast.txt); the size is not.

What to fix

findings/S4_ui.md § S4-F12 — The operator's only failure signal is 13.5 px tall, never clears, and is placed where it steals grid height: raise the banner to the scale of the rest of the board — fontSize: 22, fontWeight: w700, vertical padding 12 — and keep it to one message at a time (show the highest-priority scope, with a count badge if several). Add Diag.clearCritical('audio-init') on the first ringtone that plays without error, mirroring the pattern already used at lib/audio/audio.dart:74.

How to prove it

findings/S4_ui.md § S4-F12 — The operator's only failure signal is 13.5 px tall, never clears, and is placed where it steals grid height: critical_banner_is_legible_and_clears — assert the banner's rendered text height ≥ 20 px, and that after a successful ringtone the audio-init scope has left Diag.critical. Red now (13.5 px, scope permanent), green after. ---

S4-F14 (grid half) → merged into S6-F5; S4-F14 (tick half) → merged into S6-F2 · The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI

What is wrong

The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI

File and line

lib/ui/home.dart:515-521 vs :609-613; lib/engine/engine.dart:32 vs lib/ui/home.dart:154 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md · agent_reports/S6_refute.md. Row retained so the ID is not lost. Implement against S6-F5 and S6-F2, both confirmed by S6's refuter with exact line ranges (_panUpdate :509-530, _tileIndexAt :609-615).

findings/S4_ui.md § S4-F14 — The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI: proof/01_findings/S4/greps.txt: ### grep -rn tickMs lib/ test/ lib/engine/engine.dart:32: static const int tickMs = 150; exit=0 findings/S6_dry_deadcode.md § S6-F2 — Engine.tickMs is dead while the heartbeat that it describes hard-codes the same number: proof/01_findings/S6/dead_symbols_grep.txt === 3. Engine.tickMs — every occurrence === lib/engine/engine.dart:32: static const int tickMs = 150; === 4. the literal 150 that should have been Engine.tickMs === lib/ui/home.dart:154: _ticker = Timer.periodic(const Duration(milliseconds: 150), (_) { lib/ui/home.dart:154-159 verbatim: dart _ticker = Timer.periodic(const Duration(milliseconds: 150), (_) { final t = now(); final gap = t - _lastTickMs; // 150ms expected — a big gap means the app was frozen or CPU-starved. // ONLY meaningful while visible: a backgrounded app is suspended by // design, and logging that as a freeze would drown the real ones. findings/S6_dry_deadcode.md § S6-F5 — The grid hit-test is implemented twice in the same file, once inline and once as a helper: proof/01_findings/S6/duplication_sites.txt, D1 dart void _panUpdate(DragUpdateDetails d, List<TimerDef> view, GridLayout lay, Offset gridOrigin, Offset localPos) { if (_dragId == null) return; setState(() { _dragDelta += d.delta; // hit-test the grid geometry to find the tile beneath the pointer final col = ((localPos.dx - gridOrigin.dx) / (lay.tileW + lay.gap)).floor(); final row = ((localPos.dy - gridOrigin.dy) / (lay.rowH + lay.gap)).floor(); String? over; if (col >= 0 && col < lay.cols && row >= 0) { final idx = row * lay.cols + col; if (idx < view.length) { dart int? _tileIndexAt(Offset pos, Offset origin, GridLayout lay, int n) { final col = ((pos.dx - origin.dx) / (lay.tileW + lay.gap)).floor(); final row = ((pos.dy - origin.dy) / (lay.rowH + lay.gap)).floor(); if (col < 0 || col >= lay.cols || row < 0) return null; final idx = row * lay.cols + col; return idx < n ? idx : null; }

What to fix

findings/S4_ui.md § S4-F14 — The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI: delete the duplicated block and call _tileIndexAt(localPos, gridOrigin, lay, view.length) from _panUpdate, keeping the clone check. Replace the literal at :154 with Duration(milliseconds: Engine.tickMs). findings/S6_dry_deadcode.md § S6-F2 — Engine.tickMs is dead while the heartbeat that it describes hard-codes the same number: replace the literal at home.dart:154 with Duration(milliseconds: Engine.tickMs) (Engine is already imported at home.dart:12). Keep the constant. This is the substitutive form: one owner, one reader. findings/S6_dry_deadcode.md § S6-F5 — The grid hit-test is implemented twice in the same file, once inline and once as a helper: delete lines 515-522 of _panUpdate and call the existing helper: dart final idx = _tileIndexAt(localPos, gridOrigin, lay, view.length); String? over; if (idx != null) { final t = view[idx]; if (t.id != _dragId && !engine.isClone(t.id)) over = t.id; } _dropTargetId = over; Call sites that change: one (home.dart:509-530). _tileIndexAt gains a second caller and needs no signature change.

How to prove it

findings/S4_ui.md § S4-F14 — The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI: drag_pickup_and_drop_agree — for a 12-tile board, sweep 200 pointer positions across the grid and assert _tileIndexAt's answer equals the drop target _panUpdate records for the same point. Red now only under mutation (change one divisor in _panUpdate); include the mutation patch per R8. Plus a one-line assertion that no Duration(milliseconds: 150) literal remains in lib/ui/. --- findings/S6_dry_deadcode.md § S6-F2 — Engine.tickMs is dead while the heartbeat that it describes hard-codes the same number: a new test in test/engine_test.dart cannot see the widget, so prove it by mutation on the copy: set Engine.tickMs = 500, run flutter test, and assert with grep -c 'milliseconds: 150' lib/ui/home.dart returning 0 after the fix where it returns 1 now. Static proof is sufficient here and is the cheaper gate. --- findings/S6_dry_deadcode.md § S6-F5 — The grid hit-test is implemented twice in the same file, once inline and once as a helper: lib/ui/home.dart has 0.00 % baseline coverage, so add test/home_hittest_test.dart exercising the geometry through a public seam — extract _tileIndexAt to GridLayout as int? indexAt(Offset pos, Offset origin, int n) (already a pure, 100 %-covered class) and assert that a point in cell (1,2) returns 2*cols+1 and that a point past the last cell returns null. Mutate lay.rowH to lay.tileW in the helper: the new test goes red, and today no test in the suite moves at all. ---

S4-F15 · The UI layer's geometry lives in ~120 inline literals with no constants file

What is wrong

The UI layer's geometry lives in ~120 inline literals with no constants file

File and line

lib/ui/tile.dart, lib/ui/header.dart, lib/ui/modals.dart, lib/ui/home.dart at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md. —

findings/S4_ui.md § S4-F15 — The UI layer's geometry lives in ~120 inline literals with no constants file: the code map's repeated-literal inventory (§3.7) plus proof/01_findings/S4/greps.txt for tickMs, and direct reads of lib/ui/theme.dart:37-39, 65-68.

What to fix

findings/S4_ui.md § S4-F15 — The UI layer's geometry lives in ~120 inline literals with no constants file: add lib/ui/metrics.dart holding the tile tiers (tierWideW = 240, tierMidW = 150, minTouchWide = 42, minTouchMid = 38, hitPad*), the header ladder, the shared radii (rCard = 20, rControl = 12, rPill = 999), dialogMaxW = 560 and the animation durations; make fillFor return Color.fromARGB(255, C.fMint[0], …) instead of re-typed literals; use Engine.tickMs in the ticker. Pure substitution, no behaviour change.

How to prove it

findings/S4_ui.md § S4-F15 — The UI layer's geometry lives in ~120 inline literals with no constants file: no_repeated_magic_numbers_in_ui — a source-scanning test in the spirit of test/source_hygiene_test.dart asserting that the listed literals appear at most once in lib/ui/ outside metrics.dart/theme.dart. Red now, green after. ---

S4R-F01 · The UI audit instrument measured text 1.67× too wide and passed the same error into its proposed tests

What is wrong

The UI audit instrument measured text 1.67× too wide and passed the same error into its proposed tests

File and line

proof/01_findings/S4/tests/s4_ui_probe_test.dart; shipped fonts declared at pubspec.yaml:48-69 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S4_refute.md §9 S4R-F01 · proof/01_findings/S4_refute/font_effect_matrix.txt. Replace every geometry oracle that uses the test font with the bundled production font or a golden rendered from the shipped font assets.

agent_reports/S4_refute.md § S4R-F01 — The audit's UI instruments measure text 1.67× too wide, and the Phase-4 test suite inherits the error: proof/01_findings/S4_refute/realfont_sweep.txt: TEXTW "10" fontFamily=Chivo Mono 13px testFont=26.00 bundledFont=15.60 inflation=1.67x and the same matrix both ways, proof/01_findings/S4_refute/font_effect_matrix.txt: FONTS[testfont] SUMMARY configs=30 stopButtonClipped=19 configsWithOverflowAssertion=21 FONTS[realfont] SUMMARY configs=30 stopButtonClipped=5 configsWithOverflowAssertion=9

What to fix

agent_reports/S4_refute.md § S4R-F01 — The audit's UI instruments measure text 1.67× too wide, and the Phase-4 test suite inherits the error: move loadFonts() out of s4_screenshot_test.dart into s4_harness.dart and call it from every instrument and from every Phase-4 widget test; re-derive F01's and F02's numbers before any of them is used as an acceptance threshold.

How to prove it

agent_reports/S4_refute.md § S4R-F01 — The audit's UI instruments measure text 1.67× too wide, and the Phase-4 test suite inherits the error: run s4r_font_effect_test.dart; the testfont and realfont summaries must agree once the harness loads the fonts.

S4R-F02 · Where the dense control row overflows, the painted stop button extends beyond its tappable hit region

What is wrong

Where the dense control row overflows, the painted stop button extends beyond its tappable hit region

File and line

lib/ui/tile.dart:573-600, :677-678 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S4_refute.md §9 S4R-F02 · proof/01_findings/S4_refute/missed_findings.txt. The release path still has a real dense-board hit-test defect after S4-F01's exaggerated magnitude is removed.

agent_reports/S4_refute.md § S4R-F02 — Where the control row overflows, part of the stop button is drawn but not tappable: proof/01_findings/S4_refute/missed_findings.txt, real fonts, 1280×800: DRAWNVSTAP n=12 btnWidth=49.6 drawnInsideTile=49.6 tappableWidth=49.8 deadDrawnPx=-0.2 DRAWNVSTAP n=30 btnWidth=36.0 drawnInsideTile=36.0 tappableWidth=36.0 deadDrawnPx= 0.0 DRAWNVSTAP n=100 btnWidth=22.1 drawnInsideTile=17.8 tappableWidth=15.0 deadDrawnPx= 2.8

What to fix

agent_reports/S4_refute.md § S4R-F02 — Where the control row overflows, part of the stop button is drawn but not tappable: the same FittedBox(fit: BoxFit.scaleDown) wrapper S4 proposes for the row removes it, because the row then never exceeds its box.

How to prove it

agent_reports/S4_refute.md § S4R-F02 — Where the control row overflows, part of the stop button is drawn but not tappable: stop_button_drawn_equals_tappable — for n ∈ {12, 30, 60, 100} assert tappableWidth == drawnInsideTile using the hit-path probe in s4r_missed_test.dart.

S5-F5 · A failure raised `isCritical: true` that cannot reach the operator banner

What is wrong

A failure raised isCritical: true that cannot reach the operator banner

File and line

lib/engine/store.dart:270 raises it; lib/ui/home.dart:675-689 drops it at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S5_error_handling.md · agent_reports/S5_refute.md · proof/01_findings/S5_refute/R05_critical_scopes.txt. Both halves verbatim-exact. Correction: there are 11 distinct critical scopes, not the 12 S5 claimsaudio-init, backstop-notif, backstop-init, backstop-exact, backstop-schedule, wakelock, audio-play, voice-init, load-$key, save-$key, migrate-zone-sound. Exactly one (migrate-zone-sound) matches none of the six startsWith prefixes. S3's refuter counted five unrenderable scopes when future fixes are included (S3R-F3). The else catch-all fix is correct and within R6.

findings/S5_error_handling.md § S5-F5 — A failure raised isCritical: true that cannot reach the operator banner: the mapping, verbatim, lib/ui/home.dart:674-689: dart for (final scope in crit) { if (scope.startsWith('voice')) { msgs.add(i18n.call('voiceDown')); } else if (scope.startsWith('audio')) { ... } else if (scope.startsWith('backstop')) { msgs.add(i18n.call('backstopDown')); } } if (msgs.isEmpty) return const SizedBox.shrink(); the raise, verbatim, lib/engine/store.dart:269-272: dart } catch (err) { Diag.fail('migrate-zone-sound', err, isCritical: true); return 0; } Full enumeration of critical scopes in proof/01_findings/S5/05_diag_fail_sites.txt. Both halves proven by test group S5-F2 (proof/01_findings/S5/01_s5_tests_baseline.txt): the corrupt-zone fixture drives the real Store.migrateZoneSounds, Diag.critical.value contains migrate-zone-sound, and it matches none of the six prefixes, while all eleven other critical scopes do.

What to fix

findings/S5_error_handling.md § S5-F5 — A failure raised isCritical: true that cannot reach the operator banner: add a final else to the chain that emits a generic message rather than swallowing the scope, e.g. } else { msgs.add(i18n.call('saveFail')); } — or better, an explicit else if (scope.startsWith('migrate')) plus a migrateFail key in both _strings blocks (i18n.dart:40-131). The else catch-all is the durable fix: it also covers S5-F2's new flutter/async scopes and any future one.

How to prove it

findings/S5_error_handling.md § S5-F5 — A failure raised isCritical: true that cannot reach the operator banner: test S5-F2 … migrate-zone-sound is raised isCritical but maps to no message with expect(showsBanner('migrate-zone-sound'), isFalse) inverted to isTrue. Red now, green after. (For a real end-to-end proof, a testWidgets that pumps HomeScreen with the corrupt fixture and asserts a non-empty banner — blocked today because home.dart has 0.00% coverage and no test imports it, which is itself S5-F9.) ---

S5-F6 · Journal export failure leaves the Settings button silently reset

What is wrong

Journal export failure leaves the Settings button silently reset

File and line

lib/ui/modals.dart:698-717 (the return at :702 is inside the try, so the catch at :712 cannot see it, while the finally at :714 resets _sending), with lib/journal.dart:233-236 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S5_error_handling.md · agent_reports/S5_refute.md. Reachable without contrivance (a failing getTemporaryDirectory() or a failing copy). Two-line fix, correct, within R6.

findings/S5_error_handling.md § S5-F6 — Journal export failure leaves the Settings button silently reset: verbatim, lib/ui/modals.dart:698-717: dart Future<void> _sendJournal() async { setState(() => _sending = true); try { final path = await Journal.exportCopy(); if (path == null) return; ... } catch (e) { Diag.fail('journal-export', e); } finally { if (mounted) setState(() => _sending = false); } } and lib/journal.dart:233-236: dart } catch (e) { debugPrint('[cadence] journal export failed: $e'); return null; } Forced to fire by pointing the temporary directory at a non-existent path — test S5-F4 … exportCopy returns null on failure and the caller shows nothing (proof/01_findings/S5/01_s5_tests_baseline.txt): [cadence] journal export failed: PathNotFoundException: Cannot copy file to '/definitely/not/a/path/…' with path == null, Diag.log empty and Diag.critical.value empty.

What to fix

findings/S5_error_handling.md § S5-F6 — Journal export failure leaves the Settings button silently reset: two lines. Give Journal.exportCopy a reason: change its handler to Diag.fail('journal-export', e); return null;. And in _sendJournal, replace the bare early return with one that reports: if (path == null) { Diag.fail('journal-export', 'export produced no file'); return; }.

How to prove it

findings/S5_error_handling.md § S5-F6 — Journal export failure leaves the Settings button silently reset: the existing test with expect(Diag.log, isEmpty) changed to expect(Diag.log.map((d) => d.scope), contains('journal-export')). Red now, green after. ---

S5-F7 · The native side can never report an error, and Dart discards the booleans it does return

What is wrong

The native side can never report an error, and Dart discards the booleans it does return

File and line

MainActivity.kt:57-59, :79, and the eight catch (_: Exception) sites at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S5_error_handling.md · agent_reports/S5_refute.md · proof/01_findings/S5_refute/R14_flutter_methodchannel_runtimecatch.txt. Right half: zero result.error(...) calls in MainActivity.kt; the discarded booleans are real and production-reachable. Wrong half: the load-bearing sentence "every Dart-side .catchError on cadence/volume and cadence/tts is unreachable for native faults" is false. Flutter's embedding converts an uncaught RuntimeException from a channel handler into a PlatformException — verbatim MethodChannel.java:285-290 of the pinned 3.44.8 SDK — and MainActivity.kt has unguarded throw sites outside its try blocks at :45, :55, :83, :101-102, plus notImplemented() at :61/:109. So home.dart:211's .catchError and home.dart:227's catch are reachable. Do not write code on the refuted premise.

findings/S5_error_handling.md § S5-F7 — The native side can never report an error, and Dart discards the booleans it does return: proof/01_findings/S5/12_kotlin_no_error_path.txtgrep -n "result.error\|FlutterError" android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt → no output, EXIT_CODE=1. Verbatim, MainActivity.kt:51-60: kotlin "setAlarmVolume" -> { // The slider owns the stream — write the chosen level // straight through, whether or not a ring is in progress // (so lowering it mid-ring is heard immediately). val v = (call.arguments as Number).toDouble() val target = Math.round(v * max).toInt().coerceIn(0, max) try { audio.setStreamVolume(AudioManager.STREAM_ALARM, target, 0) } catch (_: Exception) {} result.success(null) } and MainActivity.kt:150-155: kotlin val r = try { t.speak(text, TextToSpeech.QUEUE_FLUSH, params, id) } catch (_: Exception) { TextToSpeech.ERROR } if (r != TextToSpeech.SUCCESS) { pendingSpeaks.remove(id) result.success(false) } and the Dart consumer that discards it, lib/audio/voice.dart:165-168: dart Journal.log(' parole', '"${item.text}"'); await _ch .invokeMethod('speak', {'text': item.text, 'volume': vol}) .timeout(const Duration(seconds: 12)); AppDelegate.swift has the same shape for getAlarmVolume/setAlarmVolume (:54-59, result(nil) in both cases — deliberate and documented there, since iOS genuinely has no alarm stream), but does use one real do/catch at :140-152 whose result(false) is checked by voice.dart:48. So the pattern is not uniformly wrong; it is uniformly unchecked on the Dart side for speak and setVoice.

What to fix

findings/S5_error_handling.md § S5-F7 — The native side can never report an error, and Dart discards the booleans it does return: (a) in voice.dart, capture and check the return values — final ok = await _ch.invokeMethod('speak', …); if (ok == false) Diag.fail('voice-speak', 'native refused the utterance'); and the same for setVoice before writing the voix choisie journal line; (b) in MainActivity.kt, replace the empty catch (_: Exception) {} at :58, :92, :97, :105 with catch (e: Exception) { result.error("native", e.message, null) } so the existing Dart .catchError handlers become live. Both are defect repair, not features.

How to prove it

findings/S5_error_handling.md § S5-F7 — The native side can never report an error, and Dart discards the booleans it does return: extend test/voice_test.dart's mocked cadence/tts handler to return false from speak, and assert Diag.log gains a voice-speak entry. Red now (the mock's return value is currently unobservable from Dart), green after. ---

S5-F8 · Unawaited futures with nowhere for a failure to go

What is wrong

Unawaited futures with nowhere for a failure to go

File and line

lib/main.dart:33, lib/ui/home.dart:141-144 and :202, lib/audio/audio.dart:99, :107, :114, lib/audio/voice.dart:63, :146, :177, :201, lib/journal.dart:152 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S5_error_handling.md · agent_reports/S5_refute.md. All eleven sites verified to exist. But the cited artifact does not demonstrate the claim: proof/01_findings/S5/06_unawaited_and_timeout.txt is a grep whose pattern misses 8 of the 11 sites; calling it "the full inventory" is an R2 defect. The inventory is also incomplete — it omits home.dart:86 (_boot(), which is R5-F1) and the Timer callbacks at home.dart:277, home.dart:387 and alarm_backstop.dart:147.

findings/S5_error_handling.md § S5-F8 — Unawaited futures with nowhere for a failure to go: proof/01_findings/S5/06_unawaited_and_timeout.txt for the full inventory. Verbatim, the correct pattern at lib/main.dart:28-33: dart WakelockPlus.enable().then((_) { Journal.log('ecran', 'maintien allume actif (wakelock)'); }).catchError((e) { Diag.fail('wakelock', e, isCritical: true); }); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); — line 28 has a handler; line 33, three lines later and in the same function, does not. Verbatim, the inconsistency at lib/ui/home.dart:141-144: dart voice.init(i18n.ttsLocale).then((_) { Journal.log('voix', voice.ready ? 'moteur pret (${i18n.ttsLocale})' : 'INDISPONIBLE'); }); Verbatim, lib/audio/audio.dart:111-115: dart void hapticClick() { // A 30ms buzz reads harsh on phones — a system light tick is the native // equivalent of the webapp's iOS switch tick. HapticFeedback.lightImpact(); } Proven destination-less by test S5-F3 … an unawaited future that throws reaches neither Diag nor Journal (proof/01_findings/S5/01_s5_tests_baseline.txt).

What to fix

findings/S5_error_handling.md § S5-F8 — Unawaited futures with nowhere for a failure to go: the global handler in S5-F2 fixes the whole class in one change — that is the argument for doing S5-F2 first. Independently, add .catchError((e) => Diag.fail('voice-init', e, isCritical: true)) to home.dart:141 so the boot path matches main.dart:28, and .catchError((e) => Diag.fail('journal-beat', e)) at journal.dart:152.

How to prove it

findings/S5_error_handling.md § S5-F8 — Unawaited futures with nowhere for a failure to go: a test that installs a mock cadence/tts handler which throws, calls the home.dart:141 shape, and asserts Diag.log gains a voice-init entry; plus, for the class fix, the PlatformDispatcher.instance.onError test in S5-F2. ---

S6-F1 · The release APK ships three CPU architectures; 61.5 % of it is dead weight on every device

What is wrong

The release APK ships three CPU architectures; 61.5 % of it is dead weight on every device

File and line

android/app/build.gradle.kts:30-36 — no splits/abiFilters; build command at README.md:25 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md · proof/01_findings/S6_refute/apk_rebuild_independent.txt. Rebuilt independently and reproduced to the byte: universal 53,629,091 B, arm64-only 20,623,249 B. The framing is wrong: minSdk = 24 and Google's own 64-bit requirements page permits dropping armeabi-v7a, but doing so drops 32-bit tablets — exactly the cheap hardware a restaurant buys. Prefer the App Bundle over abiFilters. Note S6R-F2: --obfuscate --split-debug-info removes a further 851,968 B and was never measured.

findings/S6_dry_deadcode.md § S6-F1 — The release APK ships three CPU architectures; 61.5 % of it is dead weight on every device: proof/01_findings/S6/apk_composition.txt === universal release APK, exact byte size === -rw-r--r-- 1 the project owner staff 53629091 Aug 4 11:37 build/app/outputs/flutter-apk/app-release.apk === per-group compressed contribution (this is what the 53.6 MB is made of) === lib/x86_64 uncompressed=18611344 compressed=18611344 files=4 lib/arm64-v8a uncompressed=17154144 compressed=17154144 files=4 lib/armeabi-v7a uncompressed=14548580 compressed=14548580 files=4 assets uncompressed=2388709 compressed=2138587 files=34 classes.dex uncompressed=1077252 compressed=490845 files=1 res uncompressed=340101 compressed=295420 files=290 resources.arsc uncompressed=117412 compressed=117412 files=1 classes2.dex uncompressed=202964 compressed=92340 files=1 kotlin uncompressed=53396 compressed=12350 files=8 META-INF uncompressed=10692 compressed=4187 files=62 AndroidManifest.xml uncompressed=10168 compressed=2662 files=1 DebugProbesKt.bin uncompressed=1728 compressed=774 files=1 kotlin-tooling-metadata.json uncompressed=626 compressed=282 files=1 Measured counterfactual, built on the copy (proof/01_findings/S6/apk_analyze_size_arm64.txt, command flutter build apk --release --analyze-size --target-platform android-arm64, EXIT_CODE=0): ✓ Built build/app/outputs/flutter-apk/app-release.apk (20.6MB) app-release.apk (total compressed) 20 MB assets/ flutter_assets 2 MB classes.dex 479 KB lib/ arm64-v8a 16 MB Dart AOT symbols accounted decompressed size 5 MB package:flutter 2 MB package:timezone 262 KB package:cadence 179 KB === arm64-only APK built on the copy for comparison === -rw-r--r-- 1 the project owner staff 20623249 Aug 4 11:59 .../app-release.apk === app bundle built on the copy === -rw-r--r-- 1 the project owner staff 51974066 Aug 4 12:00 .../app-release.aab 53,629,091 − 20,623,249 = 33,005,842 bytes removed, 61.5 % of the APK, by changing nothing but the target platform. The app bundle is 51,974,066 bytes as an upload artifact — Play splits it per device, so the tablet still downloads roughly the arm64 figure; the .aab is not itself a 53 MB install. (Its 69,186,960 bytes of BUNDLE-METADATA/com.android.tools.build.debugsymbols are stripped by Play and never delivered.)

What to fix

findings/S6_dry_deadcode.md § S6-F1 — The release APK ships three CPU architectures; 61.5 % of it is dead weight on every device: ship the app bundle for Play (flutter build appbundle --release, which is what Play requires for new apps anyway) and, for the sideloaded pilot APKs Serge hands to kitchens, flutter build apk --release --split-per-abi or --target-platform android-arm64. Update README.md:25 so the documented build command is the one that ships. No code change.

How to prove it

findings/S6_dry_deadcode.md § S6-F1 — The release APK ships three CPU architectures; 61.5 % of it is dead weight on every device: ls -l build/app/outputs/flutter-apk/app-arm64-v8a-release.apk reports ≈ 20.6 MB against today's 53,629,091, and unzip -l <apk> | grep -c 'lib/x86_64' returns 0 where it returns 4 today. ---

S6-F15 + S11-F3 · Three of the sixteen WAVs have no generator; the documented source of truth cannot reproduce them

What is wrong

Three of the sixteen WAVs have no generator; the documented source of truth cannot reproduce them

File and line

tools/build_ringtones.py:1-238 (13 write/write_peak calls: 12 tones + res/raw/cadence_alarm.wav); the three unreproducible files are assets/audio/step.wav, assets/audio/click-up.wav, assets/audio/click-down.wav at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · findings/S11_asset_licensing.md · agent_reports/S6_refute.md · agent_reports/S11_refute.md §5. Found twice — S6 from the reproducibility side, S11 from the licensing side. The measurements reproduce exactly and S11's refuter ran the flip test S11 named and failed to flip it. But S11's supporting inference — "proven synthetic → no third-party licence can attach" — is a non-sequitur. The conclusion is right for a reason S11 did not give; restate it before publishing.

findings/S11_asset_licensing.md § S11-F3 — Three shipped sounds have no generator in the repository: generator coverage (proof/01_findings/S11/04_ringtone_regen.txt) lists 13 outputs and none of these three. Blob equality across the whole history: step v0.2.0=afbcee39e9eac746 HEAD=afbcee39e9eac746 click-up v0.2.0=7d009de0eb7c2a95 HEAD=7d009de0eb7c2a95 click-down v0.2.0=0e2191bb9911d5b3 HEAD=0e2191bb9911d5b3 Synthesis markers (proof/01_findings/S11/05_ungenerated_wavs.txt): exact durations 0.620000 s / 0.320000 s / 0.320000 s, silent tails of exactly 11,025 zero samples, click-up and click-down identical in frame count, peak (0.239990), RMS (0.028446) and envelope but differing in fundamental, and click-up's partials at 1250/3750/6250/8750/11250/13750 Hz with amplitudes 1.000/0.334/0.200/0.143/0.111/0.091 — a square wave's odd-harmonic 1/n series. findings/S6_dry_deadcode.md § S6-F15 — Three of the sixteen WAVs have no generator; the documented source of truth cannot reproduce them: proof/01_findings/S6/build_ringtones_rerun.txt (EXIT_CODE=0) lists exactly what it writes, and proof/01_findings/S6/ringtone_generator_reproducibility.txt shows the byte comparison: wrote assets/audio/beep.wav wrote assets/audio/ping.wav wrote assets/audio/bell.wav wrote assets/audio/chime.wav wrote assets/audio/marimba.wav wrote assets/audio/buzz.wav wrote assets/audio/chirp.wav wrote assets/audio/coin.wav wrote assets/audio/fanfare.wav wrote assets/audio/pop.wav wrote assets/audio/cascade.wav wrote assets/audio/bowl.wav wrote android/app/src/main/res/raw/cadence_alarm.wav done. IDENTICAL assets/audio/beep.wav IDENTICAL assets/audio/bell.wav IDENTICAL assets/audio/bowl.wav IDENTICAL assets/audio/buzz.wav IDENTICAL assets/audio/cascade.wav IDENTICAL assets/audio/chime.wav IDENTICAL assets/audio/chirp.wav IDENTICAL assets/audio/click-down.wav IDENTICAL assets/audio/click-up.wav IDENTICAL assets/audio/coin.wav IDENTICAL assets/audio/fanfare.wav IDENTICAL assets/audio/marimba.wav IDENTICAL assets/audio/ping.wav IDENTICAL assets/audio/pop.wav IDENTICAL assets/audio/step.wav IDENTICAL android/app/src/main/res/raw/cadence_alarm.wav (click-down, click-up and step read IDENTICAL because the generator never touched them — it wrote 13 of the 16 files, as the wrote list above shows.)

What to fix

findings/S11_asset_licensing.md § S11-F3 — Three shipped sounds have no generator in the repository: commit the script that produced them into tools/, or regenerate them from build_ringtones.py and delete the originals so every shipped sound has its generator under version control. Then record them in LICENSES.md §3 alongside the other 13. findings/S6_dry_deadcode.md § S6-F15 — Three of the sixteen WAVs have no generator; the documented source of truth cannot reproduce them: extend tools/build_ringtones.py with the three missing recipes so it emits all 16 files, and add the same header note the other two blocks carry about which normalisation path applies. Until the recipes are recovered, the honest alternative is a one-line note in the header naming the three files as hand-authored and not regenerable — but that is documentation of a gap, not closure of it.

How to prove it

findings/S11_asset_licensing.md § S11-F3 — Three shipped sounds have no generator in the repository: the same sandbox-and-hash test that already passes for the other 13 — run the generator into a clean tree and assert SHA-256 equality for all 16 outputs. Currently that test can only cover 13 of 16. findings/S6_dry_deadcode.md § S6-F15 — Three of the sixteen WAVs have no generator; the documented source of truth cannot reproduce them: add test/assets_reproducible_test.dart asserting that every file in assets/audio/ plus android/app/src/main/res/raw/cadence_alarm.wav appears in the generator's output manifest. Concretely: run python tools/build_ringtones.py on the copy into a scratch directory and assert md5 equality for all 16. Today that test fails on exactly three files; after the fix it passes on 16. ---

S6-F16 + S9-F11 · The backstop alarm sound is invisible to the shrinker, pinned by a single `keep.xml` rule with no regression test, and has already been stripped once

What is wrong

The backstop alarm sound is invisible to the shrinker, pinned by a single keep.xml rule with no regression test, and has already been stripped once

File and line

android/app/src/main/res/raw/cadence_alarm.wav (142,928 bytes), android/app/src/main/res/raw/keep.xml:1-6, referenced from lib/alarm_backstop.dart:55 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · findings/S9_platform_config.md · agent_reports/S6_refute.md · agent_reports/S9_refute.md §Contrarian B. Found twice. Both refuters confirmed. build.gradle.kts sets neither isMinifyEnabled nor isShrinkResources, so the shrinker is off today and keep.xml is belt-and-braces — it becomes load-bearing the moment anyone enables shrinking. Do not delete keep.xml. S9's refuter proved all three states (shrinker off / on without keep / on with keep).

findings/S6_dry_deadcode.md § S6-F16 — The backstop alarm sound is invisible to the shrinker and pinned by a single keep.xml: proof/01_findings/S6/backstop_alarm_16th_wav.txt === every reference to it in the tracked tree === android/app/src/main/res/raw/keep.xml:6: tools:keep="@raw/cadence_alarm" /> lib/alarm_backstop.dart:14:// - One channel, one generic ringtone (res/raw/cadence_alarm.wav = Bell): the lib/alarm_backstop.dart:55: sound: RawResourceAndroidNotificationSound('cadence_alarm'), tools/build_ringtones.py:236:write(os.path.join(RAW, 'cadence_alarm.wav'), cat(*seq), rms_db=-8.0) === keep.xml verbatim === <?xml version="1.0" encoding="utf-8"?> <resources xmlns:tools="http://schemas.android.com/tools" tools:keep="@raw/cadence_alarm" /> === is it actually inside the shipped universal APK? (obfuscated resource name) === 142928 Stored 142928 0% 01-01-1981 01:01 3eac5c22 res/pC.wav --- extract and compare bytes --- APK res/pC.wav md5=8cdc98e2f7b9e467cb5b3c99de8fcf50 size=142928 repo res/raw/cadence_alarm md5=8cdc98e2f7b9e467cb5b3c99de8fcf50 size=142928 === shrinker flags in android/app/build.gradle.kts === (neither isMinifyEnabled nor isShrinkResources is set anywhere in the file) findings/S9_platform_config.md § S9-F11 — The release shrinker's only protection for the backstop alarm sound is a keep.xml rule with no regression test, and the resource has already been stripped once: Verbatim, android/app/src/main/res/raw/keep.xml in full, including the incident it records: xml <?xml version="1.0" encoding="utf-8"?> <resources xmlns:tools="http://schemas.android.com/tools" tools:keep="@raw/cadence_alarm" /> The only reference to it, verbatim from lib/alarm_backstop.dart:55: dart sound: RawResourceAndroidNotificationSound('cadence_alarm'), Proof the shrinker ran, and proof the keep rule is what saved the file — verbatim from the R8 resource-shrinker report build/app/outputs/mapping/release/resources.txt:201: raw:cadence_alarm:2131558400 reachable from keep xml file R8 confirmed active on the release build, build/app/outputs/mapping/release/mapping.txt:1-3: # compiler: R8 # compiler_version: 9.0.32 # min_api: 24 And the resource survives into the shipped APK, resource names obfuscated by the shrinker — the 142,928-byte source file appears as res/pC.wav: $ ls -l android/app/src/main/res/raw/cadence_alarm.wav -rw-r--r-- 1 the project owner staff 142928 Aug 4 11:17 android/app/src/main/res/raw/cadence_alarm.wav $ unzip -l build/app/outputs/flutter-apk/app-release.apk | grep -iE "\.wav" | tail -1 142928 01-01-1981 01:01 res/pC.wav

What to fix

findings/S6_dry_deadcode.md § S6-F16 — The backstop alarm sound is invisible to the shrinker and pinned by a single keep.xml: no deletion. Two protections instead: (1) add assets/audio/-equivalent coverage to the asset test — extend test/i18n_defaults_test.dart:73-77's existing "every tone has a non-empty file" assertion with a case for File('android/app/src/main/res/raw/cadence_alarm.wav') (existsSync() and lengthSync() > 1000), which currently covers only the 12 picker tones and none of step.wav, click-up.wav, click-down.wav, cadence_alarm.wav; (2) when the store-readiness work turns on isShrinkResources for release, keep.xml becomes load-bearing rather than precautionary — record it as such in android/app/build.gradle.kts next to the flag. findings/S9_platform_config.md § S9-F11 — The release shrinker's only protection for the backstop alarm sound is a keep.xml rule with no regression test, and the resource has already been stripped once: Add an automated post-build assertion to the release path that opens the produced APK or AAB and fails if no 142,928-byte res/*.wav entry is present, and keep keep.xml unchanged. A comment is not a guard; the check has to run.

How to prove it

findings/S6_dry_deadcode.md § S6-F16 — The backstop alarm sound is invisible to the shrinker and pinned by a single keep.xml: on the copy, delete android/app/src/main/res/raw/cadence_alarm.wav and run flutter test. Today the whole suite passes — 123/123, with the alarm-of-last-resort missing from the tree. After the fix the extended assertion in test/i18n_defaults_test.dart goes red and names the file. --- findings/S9_platform_config.md § S9-F11 — The release shrinker's only protection for the backstop alarm sound is a keep.xml rule with no regression test, and the resource has already been stripped once: Delete android/app/src/main/res/raw/keep.xml on a copy of the repo under a scratch working copy (per R10), run flutter build apk --release, and confirm the new assertion fails and grep -c "raw:cadence_alarm.*reachable" build/app/outputs/mapping/release/resources.txt returns 0. Restore keep.xml, rebuild, and confirm the assertion passes and the grep returns 1. ---

S6-F2 + S4-F14 (tick half) · `Engine.tickMs` is dead while the heartbeat that it describes hard-codes the same number

What is wrong

Engine.tickMs is dead while the heartbeat that it describes hard-codes the same number

File and line

lib/engine/engine.dart:32 (declaration, zero readers) and lib/ui/home.dart:154 (the literal that should read it) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · findings/S4_ui.md · agent_reports/S6_refute.md. Found twice. tickMs appears once in the whole tree. S7's refuter independently confirmed zero references in test/ and mutated 150→5000 with the suite still green.

findings/S4_ui.md § S4-F14 — The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI: proof/01_findings/S4/greps.txt: ### grep -rn tickMs lib/ test/ lib/engine/engine.dart:32: static const int tickMs = 150; exit=0 findings/S6_dry_deadcode.md § S6-F2 — Engine.tickMs is dead while the heartbeat that it describes hard-codes the same number: proof/01_findings/S6/dead_symbols_grep.txt === 3. Engine.tickMs — every occurrence === lib/engine/engine.dart:32: static const int tickMs = 150; === 4. the literal 150 that should have been Engine.tickMs === lib/ui/home.dart:154: _ticker = Timer.periodic(const Duration(milliseconds: 150), (_) { lib/ui/home.dart:154-159 verbatim: dart _ticker = Timer.periodic(const Duration(milliseconds: 150), (_) { final t = now(); final gap = t - _lastTickMs; // 150ms expected — a big gap means the app was frozen or CPU-starved. // ONLY meaningful while visible: a backgrounded app is suspended by // design, and logging that as a freeze would drown the real ones.

What to fix

findings/S4_ui.md § S4-F14 — The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI: delete the duplicated block and call _tileIndexAt(localPos, gridOrigin, lay, view.length) from _panUpdate, keeping the clone check. Replace the literal at :154 with Duration(milliseconds: Engine.tickMs). findings/S6_dry_deadcode.md § S6-F2 — Engine.tickMs is dead while the heartbeat that it describes hard-codes the same number: replace the literal at home.dart:154 with Duration(milliseconds: Engine.tickMs) (Engine is already imported at home.dart:12). Keep the constant. This is the substitutive form: one owner, one reader.

How to prove it

findings/S4_ui.md § S4-F14 — The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI: drag_pickup_and_drop_agree — for a 12-tile board, sweep 200 pointer positions across the grid and assert _tileIndexAt's answer equals the drop target _panUpdate records for the same point. Red now only under mutation (change one divisor in _panUpdate); include the mutation patch per R8. Plus a one-line assertion that no Duration(milliseconds: 150) literal remains in lib/ui/. --- findings/S6_dry_deadcode.md § S6-F2 — Engine.tickMs is dead while the heartbeat that it describes hard-codes the same number: a new test in test/engine_test.dart cannot see the widget, so prove it by mutation on the copy: set Engine.tickMs = 500, run flutter test, and assert with grep -c 'milliseconds: 150' lib/ui/home.dart returning 0 after the fix where it returns 1 now. Static proof is sufficient here and is the cheaper gate. ---

S6-F20 + S14-F5 · `analysis_options.yaml` is the untouched Flutter template: it carries an empty `linter: rules:` block, and `flutter analyze` reporting 0 issues measures almost nothing

What is wrong

analysis_options.yaml is the untouched Flutter template: it carries an empty linter: rules: block, and flutter analyze reporting 0 issues measures almost nothing

File and line

analysis_options.yaml:12-25 (the empty block); :1-29 (the whole file) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · findings/S14_entry_unowned.md · agent_reports/S6_refute.md · agent_reports/S14_refute.md §2.5. Found twice. S14's refuter reproduced the 75-issue figure twice — once with S14's saved candidate file and once with only the six named rules. S6's refuter read the block in full and confirmed it is the unmodified template. Take the MEDIUM. And add S14R-M1: the analyzer: language: dimension was never measured and hides six ERROR-severity type holes.

findings/S14_entry_unowned.md § S14-F5 — analysis_options.yaml is the untouched Flutter template: flutter analyze reporting 0 issues measures almost nothing: stock status — STOCK-TEMPLATE analysis_options.yaml in proof/01_findings/S14/stock_template_comparison.txt (SHA-256 comparison against flutter create --project-name cadence on the same Flutter 3.44.8). The rules flutter_lints 6.0.0 actually enables, verbatim from ~/.pub-cache/hosted/pub.dev/flutter_lints-6.0.0/lib/flutter.yaml: avoid_print, avoid_unnecessary_containers, avoid_web_libraries_in_flutter, no_logic_in_create_state, prefer_const_constructors_in_immutables, sized_box_for_whitespace, sort_child_properties_last, use_build_context_synchronously, use_full_hex_values_for_flutter_colors, use_key_in_widget_constructors. Measured effect of the proposed additions — proof/01_findings/S14/analyze_candidate_lints.txt (candidate file saved as proof/01_findings/S14/analysis_options_candidate.yaml [not published]), run on the pristine copy with no source change: | Rule added | Issues raised | What it catches here | |---|---|---| | discarded_futures | 35 | every fire-and-forget platform call in store.dart, voice.dart, alarm_backstop.dart, tile.dart | | avoid_catches_without_on_clauses | 30 | the bare catch (e) / catch (_) sites; 9 lib/ files have zero error handling at all | | unawaited_futures | 4 | including lib/main.dart:30 and lib/main.dart:33 | | avoid_dynamic_calls | 4 | lib/audio/voice.dart:112,113,124,127 — untyped reads of the native voice list | | avoid_slow_async_io | 1 | lib/journal.dart:71 — async dart:io on the boot path (see F2) | | prefer_final_locals | 1 | lib/ui/tile.dart:276 | | total | 75 | | Verbatim from the recorded run, the two hits inside this stream's own file: info • Missing an 'await' for the 'Future' computed by this expression. Try adding an 'await' or wrapping the expression with 'unawaited' • lib/main.dart:30:6 • unawaited_futures info • Missing an 'await' for the 'Future' computed by this expression. Try adding an 'await' or wrapping the expression with 'unawaited' • lib/main.dart:33:16 • unawaited_futures lib/main.dart:33 is SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); — a Future that is dropped, so immersive mode is not guaranteed to be applied before runApp. findings/S6_dry_deadcode.md § S6-F20 — analysis_options.yaml carries an empty linter: rules: block: analysis_options.yaml:12-25 verbatim: yaml linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` # included above or to enable additional rules. A list of all available lints # and their documentation is published at https://dart.dev/lints. # # Instead of disabling a lint rule for the entire project in the # section below, it can also be suppressed for a single line of code # or a specific dart file by using the `// ignore: name_of_lint` and # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule

What to fix

findings/S14_entry_unowned.md § S14-F5 — analysis_options.yaml is the untouched Flutter template: flutter analyze reporting 0 issues measures almost nothing: replace the empty rules: block with the six rules measured above. Two of them are mechanical to satisfy (prefer_final_locals, avoid_slow_async_io); unawaited_futures and avoid_dynamic_calls are 8 sites total; discarded_futures and avoid_catches_without_on_clauses are 65 sites and should be introduced at severity: info in an analyzer: errors: block first if the team wants a clean --fatal-infos gate immediately. Do not add always_use_package_imports: it is mutually incompatible with prefer_relative_imports (the analyser reports incompatible_lint) and lib/ consistently uses relative imports. findings/S6_dry_deadcode.md § S6-F20 — analysis_options.yaml carries an empty linter: rules: block: delete lines 12-25 and add the rules the project already satisfies, so the bar is recorded rather than accidental. At minimum, given this codebase's failure modes: unawaited_futures, avoid_dynamic_calls, cancel_subscriptions, close_sinks.

How to prove it

findings/S14_entry_unowned.md § S14-F5 — analysis_options.yaml is the untouched Flutter template: flutter analyze reporting 0 issues measures almost nothing: flutter analyze --fatal-infos --fatal-warnings must still exit 0 with the enlarged rule set — which is Gate G1 and G2 in CHECKLIST.md. The proof that the rules are live is the diff of analysis_options.yaml plus a deliberate violation (drop an await) making flutter analyze exit non-zero. --- findings/S6_dry_deadcode.md § S6-F20 — analysis_options.yaml carries an empty linter: rules: block: flutter analyze --fatal-infos --fatal-warnings must still exit 0 with the rules enabled; if it does not, each new issue is a real finding for another stream rather than a reason to leave the block empty. ---

S6-F21 + S9-F12 + S14-F7 (→S6) · The `web/` target is 7 files of unmodified stock Flutter template, shipping "A new Flutter project." and Flutter-blue branding, for a platform the product does not target

What is wrong

The web/ target is 7 files of unmodified stock Flutter template, shipping "A new Flutter project." and Flutter-blue branding, for a platform the product does not target

File and line

web/index.html:21, :26, :32; web/manifest.json:2-9; web/favicon.png; web/icons/* (4 files) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · findings/S9_platform_config.md · findings/S14_entry_unowned.md · agent_reports/S6_refute.md · agent_reports/S14_refute.md §2.6. Found three times, from the dead-code, platform-config and app-entry sides, and the prior audit's A1-5/A1-R6 make it four. S6's refuter: git log -- web/ returns exactly one commit, 22902e0, the v0.2.0 import. S14's refuter went furthest and proved the deletion is safe: 7/7 byte-identical to a fresh template, zero references, and after deletion flutter analyze = 0, suite = 123/123, flutter build apk --release = exit 0. Deleting web/ also closes S11-F4 and S9R-M02.

findings/S14_entry_unowned.md § S14-F7 — web/ is 7 files of untouched Flutter template shipping "A new Flutter project." and Flutter-blue branding, for a platform the product does not target: proof/01_findings/S14/stock_template_comparison.txt — all seven web files marked STOCK-TEMPLATE by SHA-256 against flutter create --project-name cadence on Flutter 3.44.8: STOCK-TEMPLATE web/index.html STOCK-TEMPLATE web/manifest.json STOCK-TEMPLATE web/favicon.png STOCK-TEMPLATE web/icons/Icon-192.png STOCK-TEMPLATE web/icons/Icon-512.png STOCK-TEMPLATE web/icons/Icon-maskable-192.png STOCK-TEMPLATE web/icons/Icon-maskable-512.png and $ grep -rniE "flutter build web|kIsWeb|dart:html|package:web" lib/ test/ tools/ android/ ios/ README.md pubspec.yaml (no output) findings/S6_dry_deadcode.md § S6-F21 — The web/ target is the unmodified stock Flutter template and has never been touched: proof/01_findings/S6/web_target_vestigial.txt === every commit that ever touched web/ === 22902e0 Cadence v0.2.0 — app Flutter (moteur + UI + audio natif) avec lot robustesse === web/index.html vs the stock Flutter template markers === 21: <meta name="description" content="A new Flutter project."> 26: <meta name="apple-mobile-web-app-title" content="cadence"> 32: <title>cadence</title> === any Dart code that is web-aware === (none) === is there a CI/workflow that builds web? === (no .github directory) findings/S9_platform_config.md § S9-F12 — web/manifest.json and web/index.html are the unmodified Flutter template, describing the app as "A new Flutter project.": Verbatim, web/manifest.json:2-9: json "name": "cadence", "short_name": "cadence", "start_url": ".", "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", "description": "A new Flutter project.", "orientation": "portrait-primary", Verbatim, web/index.html:21, :26 and :32: html <meta name="description" content="A new Flutter project."> <meta name="apple-mobile-web-app-title" content="cadence"> <title>cadence</title> For contrast, the real product description already exists at pubspec.yaml:2: yaml description: "Cadence — Kitchen Timer. Pro multi-timer board for restaurant kitchens."

What to fix

findings/S6_dry_deadcode.md § S6-F21 — The web/ target is the unmodified stock Flutter template and has never been touched: delete web/ entirely (7 files). The app targets Android and iOS; the directory is regenerable at any time with flutter create --platforms=web . if the product ever wants it. findings/S9_platform_config.md § S9-F12 — web/manifest.json and web/index.html are the unmodified Flutter template, describing the app as "A new Flutter project.": Set name/short_name to Cadence, description to the pubspec.yaml:2 string, background_color/theme_color to the Cadence dark background, and orientation to any; mirror the description and title into web/index.html. If the web target is not a product surface at all, delete web/ — but that is a call for the stream that owns dead code, not this one.

How to prove it

findings/S14_entry_unowned.md § S14-F7 — web/ is 7 files of untouched Flutter template shipping "A new Flutter project." and Flutter-blue branding, for a platform the product does not target: git ls-files | grep '^web/' returns nothing, flutter analyze still exits 0, flutter test count unchanged at 123, and flutter build apk --release still succeeds. --- findings/S6_dry_deadcode.md § S6-F21 — The web/ target is the unmodified stock Flutter template and has never been touched: flutter analyze --fatal-infos --fatal-warnings exits 0, flutter test stays at 123 passed, and flutter build apk --release still produces a byte-comparable APK — the web directory contributes nothing to the mobile build, which the APK content listing in proof/01_findings/S6/apk_composition.txt already shows. --- findings/S9_platform_config.md § S9-F12 — web/manifest.json and web/index.html are the unmodified Flutter template, describing the app as "A new Flutter project.": grep -c "A new Flutter project" web/manifest.json web/index.html returns 0 across both files, and python3 -c "import json;print(json.load(open('web/manifest.json'))['orientation'])" no longer prints portrait-primary. Red now, green after. ---

S6-F4 (+ S6R-F4) · The 5-second minimum duration is an invariant written at seven independent sites — plus an eighth the finding missed

What is wrong

The 5-second minimum duration is an invariant written at seven independent sites — plus an eighth the finding missed

File and line

lib/engine/engine.dart:368, :372-374; lib/ui/modals.dart:374, :388, :418, :524; lib/ui/modals.dart:436-437 (the fourth encoding, S6R-F4). lib/engine/models.dart:22 is NOT the invariant at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. Six sites enforce the floor; modals.dart:418 is the picker's snap-off-zero, which is the floor in practice because the seconds stepper moves in fives (:436-437). models.dart:22 is a JSON default, not a floor{'sec': 1} loads as 1. S6's "How to prove the fix" is also wrong: editor_layout_test.dart:76 asserts secs.first >= 5 where C.presets' smallest is 30 s, so re-pointing it at a constant set to 9 still passes. Write a new gate. Centralising without :436-437 makes the editor display one number and commit another.

findings/S6_dry_deadcode.md § S6-F4 — The 5-second minimum duration is an invariant written at seven independent sites: proof/01_findings/S6/duplication_sites.txt ```

What to fix

Implement the correction stated in findings/S6_dry_deadcode.md · agent_reports/S6_refute.md; do not change the pinned evidence location `lib/engine/engine.dart:368, :372-374; lib/ui/modals.dart:374, :388, :418, :524; lib/ui/modals.dart:436-437 (the fourth encoding, S6R-F4). lib/engine/models.dart:22 is NOT the invariant` without updating the regression citation.

How to prove it

Add a regression named S6-F4 (+ S6R-F4) that reproduces “The 5-second minimum duration is an invariant written at seven independent sites — plus an eighth the finding missed” at 03a176e72ef0075eec86b8915cbe6e93042a3b9d and asserts the opposite after the fix; run it in the whole suite under the mutation-specificity gate in §8.

S6-F5 + S4-F14 (grid half) · The grid hit-test is implemented twice in the same file, once inline and once as a helper

What is wrong

The grid hit-test is implemented twice in the same file, once inline and once as a helper

File and line

lib/ui/home.dart:509-530 (_panUpdate) and :609-615 (_tileIndexAt) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · findings/S4_ui.md · agent_reports/S6_refute.md. Found twice. _tileIndexAt is already called from onPanStart at :581-583, 30 lines above. Proposed fix is the right shape and needs no signature change.

findings/S4_ui.md § S4-F14 — The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI: proof/01_findings/S4/greps.txt: ### grep -rn tickMs lib/ test/ lib/engine/engine.dart:32: static const int tickMs = 150; exit=0 findings/S6_dry_deadcode.md § S6-F5 — The grid hit-test is implemented twice in the same file, once inline and once as a helper: proof/01_findings/S6/duplication_sites.txt, D1 dart void _panUpdate(DragUpdateDetails d, List<TimerDef> view, GridLayout lay, Offset gridOrigin, Offset localPos) { if (_dragId == null) return; setState(() { _dragDelta += d.delta; // hit-test the grid geometry to find the tile beneath the pointer final col = ((localPos.dx - gridOrigin.dx) / (lay.tileW + lay.gap)).floor(); final row = ((localPos.dy - gridOrigin.dy) / (lay.rowH + lay.gap)).floor(); String? over; if (col >= 0 && col < lay.cols && row >= 0) { final idx = row * lay.cols + col; if (idx < view.length) { dart int? _tileIndexAt(Offset pos, Offset origin, GridLayout lay, int n) { final col = ((pos.dx - origin.dx) / (lay.tileW + lay.gap)).floor(); final row = ((pos.dy - origin.dy) / (lay.rowH + lay.gap)).floor(); if (col < 0 || col >= lay.cols || row < 0) return null; final idx = row * lay.cols + col; return idx < n ? idx : null; }

What to fix

findings/S4_ui.md § S4-F14 — The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI: delete the duplicated block and call _tileIndexAt(localPos, gridOrigin, lay, view.length) from _panUpdate, keeping the clone check. Replace the literal at :154 with Duration(milliseconds: Engine.tickMs). findings/S6_dry_deadcode.md § S6-F5 — The grid hit-test is implemented twice in the same file, once inline and once as a helper: delete lines 515-522 of _panUpdate and call the existing helper: dart final idx = _tileIndexAt(localPos, gridOrigin, lay, view.length); String? over; if (idx != null) { final t = view[idx]; if (t.id != _dragId && !engine.isClone(t.id)) over = t.id; } _dropTargetId = over; Call sites that change: one (home.dart:509-530). _tileIndexAt gains a second caller and needs no signature change.

How to prove it

findings/S4_ui.md § S4-F14 — The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI: drag_pickup_and_drop_agree — for a 12-tile board, sweep 200 pointer positions across the grid and assert _tileIndexAt's answer equals the drop target _panUpdate records for the same point. Red now only under mutation (change one divisor in _panUpdate); include the mutation patch per R8. Plus a one-line assertion that no Duration(milliseconds: 150) literal remains in lib/ui/. --- findings/S6_dry_deadcode.md § S6-F5 — The grid hit-test is implemented twice in the same file, once inline and once as a helper: lib/ui/home.dart has 0.00 % baseline coverage, so add test/home_hittest_test.dart exercising the geometry through a public seam — extract _tileIndexAt to GridLayout as int? indexAt(Offset pos, Offset origin, int n) (already a pure, 100 %-covered class) and assert that a point in cell (1,2) returns 2*cols+1 and that a point past the last cell returns null. Mutate lay.rowH to lay.tileW in the helper: the new test goes red, and today no test in the suite moves at all. ---

S6-F6 · The `EngineHost` no-op implementation is copied into six test files

What is wrong

The EngineHost no-op implementation is copied into six test files

File and line

test/announcement_test.dart:23-42, test/i18n_defaults_test.dart:13-32, test/store_test.dart:12-31, test/backstop_test.dart:12-31, test/robustness_test.dart:13-34, test/engine_test.dart:7-34 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. Three of the six are md5-identical (f2198392ae74ea9e5660de7aa3bfa9d2, 20 lines each); backstop_test differs only in now(). implements EngineHost forces all nine members in every copy, so "a tenth member breaks six files" is literally true.

findings/S6_dry_deadcode.md § S6-F6 — The EngineHost no-op implementation is copied into six test files: proof/01_findings/S6/duplication_sites.txt, D4. The three identical copies, verbatim from test/store_test.dart:12-31 (announcement_test.dart:23-42 and i18n_defaults_test.dart:13-32 are the same 20 lines): dart class NullHost implements EngineHost { @override int now() => 0; @override void persistDefs() {} @override void persistRun() {} @override void persistClones() {} @override void onAlarmFire(TimerDef t) {} @override void onAlarmRepeat(TimerDef t) {} @override void onStepAdvance(TimerDef t, int advanced, int stepIndex) {} @override void onStopped(String id) {} @override void onClick(bool up) {} }

What to fix

findings/S6_dry_deadcode.md § S6-F6 — The EngineHost no-op implementation is copied into six test files: create test/support/engine_hosts.dart holding exactly three classes: class NullHost implements EngineHost (all nine members no-op, int now() => 0), class ClockHost extends NullHost { int t = 1000000; @override int now() => t; }, and class RecordingHost extends ClockHost with the fired/repeated/steps/stopped/saves fields from engine_test.dart:9-13. Call sites that change: announcement_test.dart:23-42 → delete, import NullHost; i18n_defaults_test.dart:13-32 → delete, import NullHost; store_test.dart:12-31 → delete, import NullHost; backstop_test.dart:12-31 → delete, use class FakeHost extends NullHost { @override int now() => DateTime.now().millisecondsSinceEpoch; }; robustness_test.dart:13-34 → delete, use RecordingHost (it needs t and fired only); engine_test.dart:7-34 → delete, use RecordingHost.

How to prove it

findings/S6_dry_deadcode.md § S6-F6 — The EngineHost no-op implementation is copied into six test files: add a tenth member to EngineHost on the copy. Today flutter analyze reports six errors in six files; after the fix it reports one, in test/support/engine_hosts.dart. flutter test must stay at 123 passed both before and after the refactor itself. ---

S6-F9 · `RunStatus` is re-encoded as bare strings throughout `tile.dart`

What is wrong

RunStatus is re-encoded as bare strings throughout tile.dart

File and line

lib/ui/tile.dart:101-105 (_status) and eleven comparison sites at :110, :111, :182, :185, :192, :205, :207, :208, :257, :569; twelfth site at lib/ui/home.dart:124-133 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. S6 claims a fourth enum value "would crash rather than fail to compile". It fails to compile (flutter analyze exit 1, non_exhaustive_switch_expression at home.dart:124), and with that error patched away the tile renders the fourth value without throwingr! is null-safe by construction. The twelfth site (S6R-F3) is the good one, an exhaustive switch with no default, and is the working example the fix should be modelled on.

findings/S6_dry_deadcode.md § S6-F9 — RunStatus is re-encoded as bare strings throughout tile.dart: proof/01_findings/S6/duplication_sites.txt, D17 lib/ui/tile.dart:103: if (r == null) return 'idle'; lib/ui/tile.dart:110: final ringing = _status == 'ringing'; lib/ui/tile.dart:111: final paused = _status == 'paused'; lib/ui/tile.dart:182: case 'idle': lib/ui/tile.dart:185: case 'running': lib/ui/tile.dart:192: case 'paused': lib/ui/tile.dart:205: final ringing = status == 'ringing'; lib/ui/tile.dart:207: if (status == 'idle') timeColor = C.muted; lib/ui/tile.dart:208: if (status == 'paused') { lib/ui/tile.dart:257: final breathOpacity = status == 'paused' && !_reduced lib/ui/tile.dart:569: final visible = status == 'running' || status == 'paused'; lib/ui/tile.dart:101-105 verbatim, and the default arm at :199-201: dart String get _status { final r = widget.r; if (r == null) return 'idle'; return r.status.name; } dart default: // ringing timeText = fmtUp((widget.nowMs - (r!.rangAt ?? widget.nowMs)) / 1000.0);

What to fix

findings/S6_dry_deadcode.md § S6-F9 — RunStatus is re-encoded as bare strings throughout tile.dart: change _status to RunStatus? get _status => widget.r?.status; and rewrite the eleven comparisons against the enum (_status == RunStatus.ringing, case RunStatus.running: with a null case replacing 'idle'). A switch over a nullable enum with an explicit null arm and no default makes the compiler flag any future RunStatus member. Call sites that change: eleven, all inside lib/ui/tile.dart; no other file reads _status.

How to prove it

findings/S6_dry_deadcode.md § S6-F9 — RunStatus is re-encoded as bare strings throughout tile.dart: on the copy, add a fourth value stopped to RunStatus in lib/engine/models.dart:91. Today flutter analyze stays at 0 issues and the exhaustiveness hole ships; after the fix flutter analyze reports a non-exhaustive switch in lib/ui/tile.dart. ---

S7-F10 · `TimerDef.isChain`'s `>= 2` boundary has no test

What is wrong

TimerDef.isChain's >= 2 boundary has no test

File and line

lib/engine/models.dart:52 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md. S1's refuter independently checked isChain at 0, 1 and ≥2 steps and confirmed a one-step def is not a chain.

findings/S7_tests.md § S7-F10 — TimerDef.isChain's >= 2 boundary has no test: dart // lib/engine/models.dart:51-52 // A timer is "chained" when it carries >=2 named steps. bool get isChain => steps != null && steps!.length >= 2; mutations/M18.txt → all 123 green, EXIT_CODE=0.

What to fix

findings/S7_tests.md § S7-F10 — TimerDef.isChain's >= 2 boundary has no test: in test/models_test.dart, dart test('one step is not a chain, two steps are', () { final one = TimerDef(id: 'a', name: 'a', durationSec: 10, steps: [StepDef(name: 'A', sec: 10)]); final two = TimerDef(id: 'b', name: 'b', durationSec: 20, steps: [StepDef(name: 'A', sec: 10), StepDef(name: 'B', sec: 10)]); expect(one.isChain, isFalse); expect(two.isChain, isTrue); }); Red under M18.patch, green at HEAD.

How to prove it

findings/S7_tests.md § S7-F10 — TimerDef.isChain's >= 2 boundary has no test: in test/models_test.dart, dart test('one step is not a chain, two steps are', () { final one = TimerDef(id: 'a', name: 'a', durationSec: 10, steps: [StepDef(name: 'A', sec: 10)]); final two = TimerDef(id: 'b', name: 'b', durationSec: 20, steps: [StepDef(name: 'A', sec: 10), StepDef(name: 'B', sec: 10)]); expect(one.isChain, isFalse); expect(two.isChain, isTrue); }); Red under M18.patch, green at HEAD.

S7-F11 · `test/engine_test.dart:288` names cloning but asserts on the parent

What is wrong

test/engine_test.dart:288 names cloning but asserts on the parent

File and line

test/engine_test.dart:288-295 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md §10. Verified verbatim at source.

findings/S7_tests.md § S7-F11 — test/engine_test.dart:288 names cloning but asserts on the parent: verbatim test/engine_test.dart:288-295: dart test('a batch clone rings like the dish it was cloned from', () { final t = single('p', 60)..sound = 'Cascade'; e.timers = [t]; e.startTimer(t); e.spawnClone('p'); // the clone has no def of its own — it must resolve to the parent's tone expect(e.soundFor(t), 'Cascade'); }); Probe M56 disables spawnClone outright. M56.txt reddens 8 tests — 115 + 8 = 123, so all tests ran — and this test is not among them; it passed. The eight that failed are the four ×N batches tests in engine_test.dart and the four batch-label tests in robustness_test.dart. Stated strictly: M56 is a probe, and 7 of its 8 reds are Null check operator used on a null value from spawnClone(...)! rather than assertion failures, so M56 validates none of those 8. Its evidentiary value here is entirely negative — a test that names cloning stayed green while cloning did nothing — and that negative result does not depend on how the other eight failed.

What to fix

findings/S7_tests.md § S7-F11 — test/engine_test.dart:288 names cloning but asserts on the parent: assert through the clone's own id: dart final cid = e.spawnClone('p')!; final clone = e.viewList().firstWhere((x) => x.id == cid); expect(e.soundFor(clone), 'Cascade'); Red under M56.patch, green at HEAD.

How to prove it

findings/S7_tests.md § S7-F11 — test/engine_test.dart:288 names cloning but asserts on the parent: assert through the clone's own id: dart final cid = e.spawnClone('p')!; final clone = e.viewList().firstWhere((x) => x.id == cid); expect(e.soundFor(clone), 'Cascade'); Red under M56.patch, green at HEAD.

S7-F12 · The grid geometry ratio and aspect tests are tautological

What is wrong

The grid geometry ratio and aspect tests are tautological

File and line

test/grid_layout_test.dart:14, :15, :21, :23, :38, :72 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md §10. Verified verbatim at source.

findings/S7_tests.md § S7-F12 — The grid geometry ratio and aspect tests are tautological: verbatim test/grid_layout_test.dart:69-75: dart test('une carte n est jamais plus haute que 0,85 x sa largeur', () { for (final n in [1, 2, 4, 6, 12]) { final l = GridLayout.solve(boardW, boardH, n); expect(l.rowH, lessThanOrEqualTo(l.tileW * GridLayout.maxAspect + 1), reason: '$n minuteurs'); } }); independent recomputation of GridLayout.solve at 1280×740, (cols, tileW, rowH): n=1 maxAspect 0.85 → (1, 1174, 634) maxAspect 0.95 → (1, 1174, 634) IDENTICAL n=2 maxAspect 0.85 → (2, 602, 512) maxAspect 0.95 → (2, 602, 572) DIFFERS n=4 maxAspect 0.85 → (2, 602, 332) maxAspect 0.95 → (2, 602, 332) IDENTICAL mutations/M50.txt → all 123 green, EXIT_CODE=0. The n=2 case is inside the tested list, so the test observes the changed geometry and still passes.

What to fix

findings/S7_tests.md § S7-F12 — The grid geometry ratio and aspect tests are tautological: pin the constants and the 2-timer reference geometry with literals; keep the ratio tests as invariants.

How to prove it

findings/S7_tests.md § S7-F12 — The grid geometry ratio and aspect tests are tautological: add test('the shape constants are the signed-off ones', () { expect(GridLayout.maxAspect, 0.85); expect(GridLayout.gapRatio, 0.035); expect(GridLayout.marginRatio, 0.045); }); and extend the 602×332 reference test at :110 with expect(GridLayout.solve(boardW, boardH, 2).rowH, 512);. Both red under M50.patch, green at HEAD.

S7-F14 · `I18n.ttsLocale` has no test

What is wrong

I18n.ttsLocale has no test

File and line

lib/i18n.dart:166 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md. —

findings/S7_tests.md § S7-F14 — I18n.ttsLocale has no test: dart // lib/i18n.dart:166 String get ttsLocale => lang == 'fr' ? 'fr-FR' : 'en-US'; mutations/M53.txt → all 123 green, EXIT_CODE=0.

What to fix

findings/S7_tests.md § S7-F14 — I18n.ttsLocale has no test: in test/i18n_defaults_test.dart, test('the TTS locale follows the app language', () { expect(I18n('fr').ttsLocale, 'fr-FR'); expect(I18n('en').ttsLocale, 'en-US'); }); Red under M53.patch, green at HEAD.

How to prove it

findings/S7_tests.md § S7-F14 — I18n.ttsLocale has no test: in test/i18n_defaults_test.dart, test('the TTS locale follows the app language', () { expect(I18n('fr').ttsLocale, 'fr-FR'); expect(I18n('en').ttsLocale, 'en-US'); }); Red under M53.patch, green at HEAD.

S7-F15 · `Store.lang` validation is unprotected for a well-typed but unsupported language

What is wrong

Store.lang validation is unprotected for a well-typed but unsupported language

File and line

lib/engine/store.dart:158-161 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md §10. "Strengthened by execution counts S7 did not measure." Interacts with S8-F4 (language is chosen once and an install that never seeded is pinned to English forever), same lines.

findings/S7_tests.md § S7-F15 — Store.lang validation is unprotected for a well-typed but unsupported language: dart // lib/engine/store.dart:158-161 String get lang { final v = _readString(_kLang); return (v == 'fr' || v == 'en') ? v! : 'en'; } mutations/M32.txt → all 123 green, EXIT_CODE=0.

What to fix

findings/S7_tests.md § S7-F15 — Store.lang validation is unprotected for a well-typed but unsupported language: in test/store_test.dart, dart test('an unsupported stored language falls back to English', () async { SharedPreferences.setMockInitialValues({'cadence-lang': 'de'}); expect((await Store.open()).lang, 'en'); }); Red under M32.patch, green at HEAD.

How to prove it

findings/S7_tests.md § S7-F15 — Store.lang validation is unprotected for a well-typed but unsupported language: in test/store_test.dart, dart test('an unsupported stored language falls back to English', () async { SharedPreferences.setMockInitialValues({'cadence-lang': 'de'}); expect((await Store.open()).lang, 'en'); }); Red under M32.patch, green at HEAD.

S7-F19 · One test's pass depends on a 50 ms wall-clock margin

What is wrong

One test's pass depends on a 50 ms wall-clock margin

File and line

test/backstop_test.dart:169; supporting test/voice_test.dart:59, :77, :94 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md §7. The refuter ran five further whole-suite runs (2 identical, 2 fresh random seeds, 1 fixed seed) — 123 pass, exit 0, every time. Thirteen independent runs now agree the suite is order-independent and flake-free today; the margin remains a latent flake source.

findings/S7_tests.md § S7-F19 — One test's pass depends on a 50 ms wall-clock margin: verbatim test/backstop_test.dart:169-172: dart await Future<void>.delayed(const Duration(milliseconds: 350)); // exactly ONE re-schedule, at the FINAL deadline, not five expect(scheduled(), hasLength(1)); expect(modeOf(scheduled().single), 'alarmClock'); eight recorded runs: flake_run_1..5.txt, random_order_1..3.txt, each ending All tests passed! / EXIT_CODE=0.

What to fix

findings/S7_tests.md § S7-F19 — One test's pass depends on a 50 ms wall-clock margin: convert both suites to package:fake_async; the 350 ms wait becomes async.elapse(const Duration(milliseconds: 301)).

How to prove it

findings/S7_tests.md § S7-F19 — One test's pass depends on a 50 ms wall-clock margin: the converted test asserts the boundary exactly — nothing scheduled after elapse(299 ms), exactly one schedule after 2 ms more — and still passes with the suite run under nice -n 19 alongside a CPU-saturating load. ---

S7-F7 · Notification ids have no test: every timer can collapse onto one id

What is wrong

Notification ids have no test: every timer can collapse onto one id

File and line

lib/alarm_backstop.dart:67 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md. S3's refuter independently mutated _nid (mutation B1) and the suite detected it — so the id scheme has some protection, but no test names it. Cross-reference S3-F14, where per-clone _nid is what stacks three identical notifications.

findings/S7_tests.md § S7-F7 — Notification ids have no test: every timer can collapse onto one id: dart // lib/alarm_backstop.dart:66-67 /// Stable 31-bit notification id per timer id. int _nid(String id) => id.hashCode & 0x7fffffff; mutations/M47.txt → all 123 green, EXIT_CODE=0.

What to fix

findings/S7_tests.md § S7-F7 — Notification ids have no test: every timer can collapse onto one id: in backstop_test.dart, sync two running timers and expect(scheduled().map((c) => (c.arguments as Map)['id']).toSet(), hasLength(2)); Red under M47.patch, green at HEAD.

How to prove it

findings/S7_tests.md § S7-F7 — Notification ids have no test: every timer can collapse onto one id: in backstop_test.dart, sync two running timers and expect(scheduled().map((c) => (c.arguments as Map)['id']).toSet(), hasLength(2)); Red under M47.patch, green at HEAD.

S7-F9 · Nine of the ten `fromJson` fallback defaults are unprotected

What is wrong

Nine of the ten fromJson fallback defaults are unprotected

File and line

lib/engine/models.dart:22 (×2), :67, :68, :72, :131-132, :134, :139, :158 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md §6. The refuter audited the proposed models_test.dart spec and found it implementable and killable — it kills mutations M18–M22 and M24–M28. legacyZoneId exists at models.dart:39, is read at :78, and is absent from toJson (:56-63), so spec test 3 is exactly right.

findings/S7_tests.md § S7-F9 — Nine of the ten fromJson fallback defaults are unprotected: verbatim lib/engine/models.dart:130-141: dart factory RunEntry.fromJson(Map<String, dynamic> j) => RunEntry( status: RunStatus.values.firstWhere((s) => s.name == j['status'], orElse: () => RunStatus.running), chain: j['chain'] == 1 || j['chain'] == true, stepIndex: (j['stepIndex'] ?? 0) as int, endsAt: j['endsAt'] as int?, remainingMs: j['remainingMs'] as int?, rangAt: j['rangAt'] as int?, armedAt: j['armedAt'] as int?, voiceGap: (j['voiceGap'] ?? 7000) as int, nextVoiceAt: j['nextVoiceAt'] as int?, ); nine recorded runs M19/M20/M21/M22/M24/M25/M26/M27/M28.txt, each +123: All tests passed!, EXIT_CODE=0, nine distinct patches.

What to fix

findings/S7_tests.md § S7-F9 — Nine of the ten fromJson fallback defaults are unprotected: one table-driven test asserting every documented default.

How to prove it

findings/S7_tests.md § S7-F9 — Nine of the ten fromJson fallback defaults are unprotected: in test/models_test.dart, dart test('every fromJson fallback default is the documented one', () { expect(StepDef.fromJson({}).sec, 5); expect(StepDef.fromJson({}).name, 'Step'); final t = TimerDef.fromJson({'id': 'a'}); expect([t.name, t.durationSec, t.sound, t.phrase], ['Timer', 0, kDefaultSound, '']); final r = RunEntry.fromJson({}); expect([r.status, r.stepIndex, r.voiceGap], [RunStatus.running, 0, Engine.firstVoiceGapMs]); expect(CloneRef.fromJson({'id': 'a', 'parentId': 'p'}).batchNo, 0); }); Red under each of M19M22, M24M28; green at HEAD.

S7R-F1 · The "19 of 123" headline row states as measured what §1b explicitly disclaims

What is wrong

The "19 of 123" headline row states as measured what §1b explicitly disclaims

File and line

findings/S7_tests.md §0 headline table, row "Distinct tests thereby individually validated" at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S7_refute.md §5 · proof/01_findings/S7_refute/rerun_comparison.txt. The measured fact is "19 tests are individually pinned by a single-test assertion kill". The implication a reader takes is "104 tests are unproven". A bound S7 never computed says the opposite more strongly: 43 distinct tests of 123 went red under at least one mutation. Publish 43, not 19 alone.

agent_reports/S7_refute.md § S7R-F1 — The 19 of 123 headline row states as measured what §1b explicitly disclaims: verbatim, proof/01_findings/S7_refute/rerun_comparison.txt: Distinct tests reddened by >=1 mutation: 43 Distinct tests reddened ALONE by >=1 mutation: 19 ...of which at least one such mutation was an ASSERTION failure (result=failure, mode=A): 19 and S7's own §1b: "104 of the 123 tests are not individually validated by this campaign. For most that is a limit of the campaign, not evidence against the test."

What to fix

agent_reports/S7_refute.md § S7R-F1 — The 19 of 123 headline row states as measured what §1b explicitly disclaims: change the row to two rows — Distinct tests reddened by ≥1 mutation | 43 of 123 and Distinct tests individually pinned (single-test assertion kill) | 19 of 123 — and append to the second "(the other 104 are unvalidated by this campaign, not disproven)".

How to prove it

agent_reports/S7_refute.md § S7R-F1 — The 19 of 123 headline row states as measured what §1b explicitly disclaims: the two numbers reconcile against rerun_results.json; no test change needed.

S7R-F2 · S7 never used the `--reporter=json` capture R8(a) requires; every one of its 57 runs used the expanded reporter

What is wrong

S7 never used the --reporter=json capture R8(a) requires; every one of its 57 runs used the expanded reporter

File and line

All 57 of proof/01_findings/S7/mutations/M01..M57.txt at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S7_refute.md §5. The conclusions happen to be right — the refuter re-ran all 57 under json and agrees 57/57 — but S7's own records cannot demonstrate R8(a) or R8(b), because the fields are not in the capture. See EVIDENCE_INTEGRITY.md §3.

agent_reports/S7_refute.md § S7R-F2 — S7 never used the --reporter=json capture R8(a) requires; every one of its 57 runs used the expanded reporter: verbatim, proof/01_findings/S7_refute/s7_tree_state_absent.txt: reporter used by all 57 mutation runs: 57 COMMAND: flutter test --reporter expanded and a repository-wide search of S7's proof directory for a json-reporter capture returns nothing. One consequence is visible in S7's data: S7 grades M35's second red test (volume_test.dart:160, a testWidgets) as mode A. Its underlying cause is indeed a TestFailure, but its json result is "error", not "failure" — verbatim from my capture: M35 KILLED A failure | volume_test.dart | plancher audible 15 % ... sane() releve tout ce qu... T error | volume_test.dart | slider des Reglages le slider NE PEUT PAS descendre sous 15 err: Test failed. See exception logs above. This is structural: flutter_test reports every testWidgets failure as result: "error", so a literal reading of R8(b) would disqualify all 8 widget tests from ever being validated. It does not touch the 19 — none of them is a testWidgets.

What to fix

agent_reports/S7_refute.md § S7R-F2 — S7 never used the --reporter=json capture R8(a) requires; every one of its 57 runs used the expanded reporter: cite proof/01_findings/S7_refute/reruns/M01..M57.json.txt as the R8(a)-compliant capture of the same 57 mutations, and note in R8 that testWidgets assertion failures arrive as result: "error" so the rule is read as "not a load-time error" rather than "result must be failure".

How to prove it

agent_reports/S7_refute.md § S7R-F2 — S7 never used the --reporter=json capture R8(a) requires; every one of its 57 runs used the expanded reporter: grep -c '"type":"testDone"' over any of my 57 captures returns the full suite; parse_json_report in s7r_driver.py reproduces the failing set for any mutation.

S7R-F3 · Not one of S7's 69 recorded files carries a `TREE_STATE` stamp, and R8(d) is evidenced by a single end-of-campaign check

What is wrong

Not one of S7's 69 recorded files carries a TREE_STATE stamp, and R8(d) is evidenced by a single end-of-campaign check

File and line

Every .txt under proof/01_findings/S7/; proof/01_findings/S7/revert_clean_check.txt at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S7_refute.md §5, §8. R8(d) is evidenced by one aggregate git status --porcelain taken at 10:08:15Z, after all 57 runs (10:02–10:08) had finished. That proves the tree was clean at the end, not between runs. See EVIDENCE_INTEGRITY.md §1.

agent_reports/S7_refute.md § S7R-F3 — Not one of S7's 69 recorded files carries a TREE_STATE stamp, and R8(d) is evidenced by a single end-of-campaign check: verbatim, proof/01_findings/S7_refute/s7_tree_state_absent.txt: files under proof/01_findings/S7/ carrying a TREE_STATE line: 0 total .txt files: 69 Other streams' captures do carry it (68 files across proof/01_findings/), so this is S7-specific, not a harness gap. S7's whole R8(d) evidence, verbatim, is proof/01_findings/S7/revert_clean_check.txt — a single git status --porcelain, EXIT_CODE=0, TIMESTAMP: 2026-08-04T10:08:15Z.

What to fix

agent_reports/S7_refute.md § S7R-F3 — Not one of S7's 69 recorded files carries a TREE_STATE stamp, and R8(d) is evidenced by a single end-of-campaign check: cite proof/01_findings/S7_refute/reruns/ as the tree-state-stamped record of the same campaign; do not re-run S7's.

How to prove it

agent_reports/S7_refute.md § S7R-F3 — Not one of S7's 69 recorded files carries a TREE_STATE stamp, and R8(d) is evidenced by a single end-of-campaign check: grep -c '^TREE_STATE:' proof/01_findings/S7_refute/reruns/*.txt returns 57 of 57, and rerun_results.json shows pre_porcelain == "" and post_revert_porcelain == "" for every mutation.

S7R-F6 · Five of the thirteen test files were never reddened by any of the 57 mutations, and S7 does not report it

What is wrong

Five of the thirteen test files were never reddened by any of the 57 mutations, and S7 does not report it

File and line

test/announcement_test.dart (267 lines, 13 tests), test/editor_layout_test.dart (79, 3 tests), test/i18n_defaults_test.dart (117, 6), test/source_hygiene_test.dart (25, 1), test/version_test.dart (29, 1) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S7_refute.md §5. Five of thirteen files, carrying 24 of the 123 tests, never went red once across all 57 mutations. S7's manifest reports which mutations "land" in each file but never the inverse and more useful measure.

agent_reports/S7_refute.md § S7R-F6 — Five of the thirteen test files were never reddened by any of the 57 mutations, and S7 does not report it: verbatim, proof/01_findings/S7_refute/rerun_comparison.txt: Test files reddened by at least one mutation: backstop_test.dart 4 mutations engine_test.dart 9 mutations grid_layout_test.dart 1 mutations journal_test.dart 2 mutations robustness_test.dart 9 mutations store_test.dart 5 mutations voice_test.dart 3 mutations volume_test.dart 4 mutations Test files NEVER reddened by any of the 57: ['announcement_test.dart', 'editor_layout_test.dart', 'i18n_defaults_test.dart', 'source_hygiene_test.dart', 'version_test.dart'] Two of the five are unfalsifiable by design and are not a defect: source_hygiene_test.dart scans lib/ text for restaurant identifiers and version_test.dart compares kAppVersion against pubspec.yaml — neither exercises a lib/ code path. editor_layout_test.dart is now proven live: my N05 (preset chip label padded to three digits) reddens both of its language variants, which confirms S7's §3 claim that its duplicated presetLabel helper "fails closed". That leaves announcement_test.dart (13 tests) and i18n_defaults_test.dart (6 tests) with no mutation evidence at all — 19 of the 123 tests whose sensitivity is entirely unmeasured.

What to fix

agent_reports/S7_refute.md § S7R-F6 — Five of the thirteen test files were never reddened by any of the 57 mutations, and S7 does not report it: report the inverse metric in S7 §3, correct the announcement_test.dart manifest row, and add two mutations targeting I18n.announcementFor and Store.wasGeneratedByUs to close the two files.

How to prove it

agent_reports/S7_refute.md § S7R-F6 — Five of the thirteen test files were never reddened by any of the 57 mutations, and S7 does not report it: a mutation inverting the phrase.isNotEmpty branch at lib/i18n.dart:164 must redden announcement_test.dart; if it does not, the file has a real defect and not merely an unmeasured one. ---

S8-F4 · Language is chosen once, and an install that never seeded is pinned to English forever

What is wrong

Language is chosen once, and an install that never seeded is pinned to English forever

File and line

lib/engine/store.dart:300-310 and :158-161 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S8_i18n.md · agent_reports/S8_refute.md. Confirmed by S8's refuter; both line ranges also independently verified by S2's refuter (for S2-F9) and by S7 (for S7-F15).

findings/S8_i18n.md § S8-F4 — Language is chosen once, and an install that never seeded is pinned to English forever: proof/01_findings/S8/probe_i18n.txt, PROBE-5: Store.lang stored={} -> "en" EXISTING_DATA_NO_SEED_FLAG deviceLang=fr seeded=false Store.lang="en"

What to fix

findings/S8_i18n.md § S8-F4 — Language is chosen once, and an install that never seeded is pinned to English forever: make Store.lang's fallback consult the platform rather than hard-code English: return (v == 'fr' || v == 'en') ? v! : seedLangFor(PlatformDispatcher.instance.locale.languageCode);seedLangFor is already pure, static, and tested, so the rule stays in one place.

How to prove it

findings/S8_i18n.md § S8-F4 — Language is chosen once, and an install that never seeded is pinned to English forever: a store_test.dart case that sets mock prefs to a timer list with no cadence-lang, and asserts store.lang == 'fr' under a French platform locale. Red today. ---

S8-F5 · The app's own name is inconsistent across the four places an operating system reads it

What is wrong

The app's own name is inconsistent across the four places an operating system reads it

File and line

android/app/src/main/AndroidManifest.xml:19not :22, which is <activity; ios/Runner/Info.plist (CFBundleDisplayName, CFBundleName); web/manifest.json:2-3; lib/main.dart:44 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S8_i18n.md · agent_reports/S8_refute.md §2 S8-R5. grep -n 'android:label' android/app/src/main/AndroidManifest.xml returns 19: android:label="Cadence" and nothing else. This matters more than a normal citation slip: S8-F5 is the rename inventory, a 22-item checklist where items 3 and 7 are irreversible after first publish. The web/manifest.json:2-3 half is independently confirmed by S9-F12/S6-F21/S14-F7; the iOS half is adjacent to S9R-M03.

findings/S8_i18n.md § S8-F5 — The app's own name is inconsistent across the four places an operating system reads it: proof/01_findings/S8/platform_app_names.txt: | Where the OS reads it | Value shipped | Consequence | |---|---|---| | Android launcher / app list | android:label="Cadence" (AndroidManifest.xml:22; confirmed in the merged release manifest, proof/01_findings/S13/apk_permissions.txt, line android:label="Cadence") | Cadence | | Android task switcher | MaterialApp.title: 'Cadence — Kitchen Timer' (lib/main.dart:44) | Cadence — Kitchen Timer | | iOS home screen | CFBundleDisplayName = Cadence | Cadence | | iOS elsewhere (Settings, storage list) | CFBundleName = cadence, lower-case | cadence | | Web / installed web app | manifest.json "name": "cadence", "short_name": "cadence"; index.html <title>cadence</title>, apple-mobile-web-app-title cadence | cadence | | In-app header | 'CADENCE' + ' — Kitchen Timer' (lib/ui/header.dart:67,79) | CADENCE — Kitchen Timer | | Android package id | dev.sergemio.cadence (android/app/build.gradle.kts:8,21) | permanent once published | The web/ shell is additionally the untouched Flutter template — "description": "A new Flutter project." and theme_color/background_color #0175C2 (Flutter blue, not the app's beige). That half is prior finding A1-5 / A1-R6, already recorded as STILL OPEN in research/01_prior_work.md §2.1-2.2, and is not re-derived here.

What to fix

findings/S8_i18n.md § S8-F5 — The app's own name is inconsistent across the four places an operating system reads it: decide the name first (the project owner's call, README.md:38), then change items 1-12 and 19-22 freely; items 13-18 need a migration or a deliberate decision to strand the old keys; items 3 and 7 must be settled before the first store submission because they cannot be changed after.

How to prove it

findings/S8_i18n.md § S8-F5 — The app's own name is inconsistent across the four places an operating system reads it: a source_hygiene_test.dart-style test asserting that the display name in AndroidManifest.xml, Info.plist (CFBundleDisplayName and CFBundleName), web/manifest.json and MaterialApp.title all derive from one constant. Red today (four different strings). ---

S8-F6 · The Android notification channel is English-only, in the one recovery screen the app points at

What is wrong

The Android notification channel is English-only, in the one recovery screen the app points at

File and line

lib/alarm_backstop.dart:44-59not :46-49; the type is AndroidNotificationDetails with channelDescription:, not AndroidNotificationChannel with description:; consumed through static const NotificationDetails _details = NotificationDetails(android: _channel) at :60-61 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S8_i18n.md · agent_reports/S8_refute.md §2 S8-R4. S8-F6's "verbatim" block quotes a class that appears nowhere in lib/grep -rn 'AndroidNotificationChannel\|createNotificationChannel' lib/ exits 1. Its claim that "the channel is created once at :78-80 inside init()" is also false: :76-82 are permission requests and a Journal.log; flutter_local_notifications creates the channel implicitly from the details on the first notification. The conclusion survives (the two strings are hardcoded English and are the recovery screen the French banner sends a chef to) but the fix does not: making it language-aware means dropping both consts and threading an I18n through Backstop and every call site, not adding one construction in init().

findings/S8_i18n.md § S8-F6 — The Android notification channel is English-only, in the one recovery screen the app points at: proof/01_findings/S8/hardcoded_string_sites.txt, section --- lib/alarm_backstop.dart (Android notification channel + titles) ---. The channel is created once at lib/alarm_backstop.dart:78-80 inside init(). agent_reports/S8_refute.md § S8-R4 — S8-F6's evidence quotes a class that does not exist in the codebase, and its fix targets the wrong construct: the two code blocks above, sed -n '44,59p' lib/alarm_backstop.dart and sed -n '76,82p' lib/alarm_backstop.dart, plus the grep exit code.

What to fix

findings/S8_i18n.md § S8-F6 — The Android notification channel is English-only, in the one recovery screen the app points at: pass the channel name and description from I18n at construction time. Because the channel is immutable once created, add the strings as new i18n keys and construct _channel inside init() from I18n(store.lang) rather than as a static const — accepting that existing installs keep the English channel until it is recreated under a new id. agent_reports/S8_refute.md § S8-R4 — S8-F6's evidence quotes a class that does not exist in the codebase, and its fix targets the wrong construct: replace the two static const fields with an instance field built from the I18n the Backstop is constructed with, and pass _details at every call site. Accept that installs which already created the channel keep the English one until the id changes.

How to prove it

findings/S8_i18n.md § S8-F6 — The Android notification channel is English-only, in the one recovery screen the app points at: a test that constructs Backstop with a French I18n and asserts the channel name equals the French key. Red today (the field is static const, so no test can vary it). --- agent_reports/S8_refute.md § S8-R4 — S8-F6's evidence quotes a class that does not exist in the codebase, and its fix targets the wrong construct: a test constructing Backstop with I18n('fr') and asserting the details' channel name is the French string. Red today — the field is static const, so no test can vary it, which is the one part of S8-F6 that is exactly right.

S8-F7 · Numbers, durations and the clock are formatted by hand, with two concrete defects

What is wrong

Numbers, durations and the clock are formatted by hand, with two concrete defects

File and line

lib/ui/theme.dart:77-82; lib/ui/header.dart:143-148; lib/ui/modals.dart:637 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S8_i18n.md · agent_reports/S8_refute.md. Confirmed. Overlaps S6-F18 (four independent copies of a two-digit zero-pad), whose refuter REFUTED the pad2 half as a library idiom rather than logic. S8's defect is the formatting semantics, not the duplication — the two findings survive independently. Also overlaps S4-F17.

findings/S8_i18n.md § S8-F7 — Numbers, durations and the clock are formatted by hand, with two concrete defects: proof/01_findings/S8/probe_i18n.txt, PROBE-8 and PROBE-7: fmtTime(90)="1:30" fmtTime(3600)="60:00" // 60 minutes, drawn as sixty-something-colon-zero-zero fmtTime(7325)="122:05" // the 180-minute editor ceiling would read "180:00" volume readout for 0.5 -> "50 %" header clock hour=0 -> "00" hour=13 -> "13" hour=19 -> "19" formatTimeOfDay(19:05)="7:05 PM" // what the framework would have produced

What to fix

findings/S8_i18n.md § S8-F7 — Numbers, durations and the clock are formatted by hand, with two concrete defects: (a) extend fmtTime to emit h:mm:ss above 3,600 seconds — it is a pure function at 100 %-testable altitude already (decision L5's remedy, research/01_prior_work.md §3). (b) Move the percent format into i18n.dart as a key and fix lib/i18n.dart:115 to match. (c) Keep 24-hour as a deliberate product decision and record it, or drive it from MediaQuery.of(context).alwaysUse24HourFormat once S8-F1 is fixed. No new feature either way.

How to prove it

findings/S8_i18n.md § S8-F7 — Numbers, durations and the clock are formatted by hand, with two concrete defects: theme.dart has 0.00 % coverage today (proof/00_baseline/coverage.txt), so any test is new. Add a fmtTime table test including fmtTime(3600) == '1:00:00' and fmtTime(10800) == '3:00:00' — red today ('60:00', '180:00'). ---

S8-F8 · A missing key renders its own identifier on screen

What is wrong

A missing key renders its own identifier on screen

File and line

lib/i18n.dart:143-145 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S8_i18n.md · agent_reports/S8_refute.md §1, §2 S8-R1. The refuter measured call(missingKeyXyz)="missingKeyXyz" and FR_MISSING_FALLS_BACK_TO_EN=false, and proved the raw identifier reaches the screen. S8 rated it LOW because "parity is perfect"; the refuter showed parity is not the guard — see S8-R1.

findings/S8_i18n.md § S8-F8 — A missing key renders its own identifier on screen: proof/01_findings/S8/probe_i18n.txt, PROBE-3, forced: MISSING_KEY lang=fr result="thisKeyDoesNotExist" isKeyItself=true isEmpty=false MISSING_KEY lang=en result="thisKeyDoesNotExist" isKeyItself=true isEmpty=false MISSING_KEY_DOES_NOT_FALL_BACK_TO_EN=true MISSING_TONE lang=fr result="NoSuchTone" And the unsupported-locale path, PROBE-4, which is the correct half of the design — every unknown language silently resolves to English rather than crashing: UNSUPPORTED lang="de" call(save)="Save" toneLabel(Bell)="Bell" ttsLocale="en-US" UNSUPPORTED lang="fr-FR" call(save)="Save" toneLabel(Bell)="Bell" ttsLocale="en-US" UNSUPPORTED lang="" call(save)="Save" toneLabel(Bell)="Bell" ttsLocale="en-US" Note fr-FR resolving to English: I18n matches the language string exactly. This is unreachable at runtime, because Store.lang (lib/engine/store.dart:158-161) admits only the exact strings 'fr' and 'en' and the Settings buttons pass exactly those (lib/ui/modals.dart:624,626). The guard is correct and is the reason the class of defect does not ship.

What to fix

findings/S8_i18n.md § S8-F8 — A missing key renders its own identifier on screen: change the fallback chain to try English before surrendering: _strings[lang]?[key] ?? _strings['en']![key] ?? key. Two-token change, same signature.

How to prove it

findings/S8_i18n.md § S8-F8 — A missing key renders its own identifier on screen: replace test/i18n_defaults_test.dart:80-82 with a case that asserts a key present in en and absent from fr returns the English string, using a @visibleForTesting injection point or a key genuinely removed from fr in the test fixture. Red today (returns the key). ---

S8-R1 (S8's refuter) · A key deleted from **both** locale maps renders raw on screen and the whole suite stays green

What is wrong

A key deleted from both locale maps renders raw on screen and the whole suite stays green

File and line

lib/i18n.dart:143; the guard under test at test/i18n_defaults_test.dart:38-45; render site lib/ui/modals.dart:683 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S8_refute.md §2. The parity test compares the two maps against each other, so deleting a key from both leaves parity perfect and the suite green while the identifier renders on screen. This is why S8-F8 is not LOW.

agent_reports/S8_refute.md § S8-R1 — A key deleted from both locale maps renders raw on screen and the whole suite stays green: I deleted journalHint from both locale maps on the copy (proof/01_findings/S8_refute/journalhint_mutation.patch) and ran the whole suite: TESTS_RUN=123 NON_SUCCESS=0 DETAIL: (none) (proof/01_findings/S8_refute/full_suite_under_journalHint_deletion.txt, --reporter=json.) 123 tests, zero failures, zero errors, against a build in which the Settings panel now draws the literal string journalHintlib/ui/modals.dart:683 is Text('${tr('journalHint')}\n${Journal.device}', …), a plain Text with no transformation. I then reproduced the on-screen rendering directly, by deleting save and namePh from both maps and pumping the real editor (proof/01_findings/S8_refute/rawkey_mutated.txt, patch rawkey_mutation.patch). Every Text widget on screen, verbatim: LANG=fr EVERY_TEXT_WIDGET=[open, NOUVEAU TIMER, NOM (AFFICHÉ SUR LA TUILE), namePh, TYPE DE TIMER, … ANNONCE VOCALE, Les frites sont prêtes, ANNULER, SAVE] The control run with the keys present shows Frites and ENREGISTRER in those two slots (proof/01_findings/S8_refute/rawkey_control.txt).

What to fix

agent_reports/S8_refute.md § S8-R1 — A key deleted from both locale maps renders raw on screen and the whole suite stays green: two lines, no new capability. (a) Change the lookup at lib/i18n.dart:143 to _strings[lang]?[key] ?? _strings['en']![key] ?? key — S8-F8's fix, which I endorse. (b) Add to test/i18n_defaults_test.dart a frozen list of the 37 key names and assert both maps contain every one, so deletion from both maps goes red.

How to prove it

agent_reports/S8_refute.md § S8-R1 — A key deleted from both locale maps renders raw on screen and the whole suite stays green: apply journalhint_mutation.patch and re-run the suite. Today: 123 pass. After (b): exactly the new key-set test fails.

S8-R2 (S8's refuter) · The editor shows the operator the ungrammatical French phrase, live, next to a correct one

What is wrong

The editor shows the operator the ungrammatical French phrase, live, next to a correct one

File and line

lib/ui/modals.dart:235-242, wired at :337; the correct string is at lib/i18n.dart:58 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S8_refute.md §2. S8-F2 treats the readyPhrase grammar defect as something the operator hears. It is also something the operator sees, in the editor, immediately beside a correctly-worded string — which makes it a visible product-quality defect during a sales demo, not only an audio one.

agent_reports/S8_refute.md § S8-R2 — The editor shows the operator the ungrammatical French phrase, live, next to a correct one: the code block above, lib/ui/modals.dart:235-242, plus lib/i18n.dart:58 ('voicePh': 'Les frites sont prêtes') and the measured generator output (proof/01_findings/S8_refute/probe_s8_refute.txt, R-PROBE-3): NAME="Frites" EN="The frites is ready" FR="Frites est prêt" NAME="Pizza" EN="The pizza is ready" FR="Pizza est prêt" The pumped editor confirms the empty-name half is on screen: … ANNONCE VOCALE, Les frites sont prêtes, ANNULER, SAVE (proof/01_findings/S8_refute/rawkey_control.txt).

What to fix

agent_reports/S8_refute.md § S8-R2 — The editor shows the operator the ungrammatical French phrase, live, next to a correct one: make _voiceHint and voicePh come from one generator, so the empty-field example and the live preview cannot disagree. Fixing readyPhrase per S8-F2(a) ('$name : c\'est prêt') does this automatically once voicePh is derived from it.

How to prove it

agent_reports/S8_refute.md § S8-R2 — The editor shows the operator the ungrammatical French phrase, live, next to a correct one: a widget test that types Frites into the name field and asserts the announcement placeholder equals the agreed correct string. Red today (Frites est prêt).

S8-R4 (S8's refuter) · S8-F6's evidence quotes a class that does not exist in the codebase, and its fix targets the wrong construct

What is wrong

S8-F6's evidence quotes a class that does not exist in the codebase, and its fix targets the wrong construct

File and line

lib/alarm_backstop.dart:44-59 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S8_refute.md §2. Detail in the S8-F6 row above. Implement the refuter's fix, not S8's.

agent_reports/S8_refute.md § S8-R4 — S8-F6's evidence quotes a class that does not exist in the codebase, and its fix targets the wrong construct: the two code blocks above, sed -n '44,59p' lib/alarm_backstop.dart and sed -n '76,82p' lib/alarm_backstop.dart, plus the grep exit code.

What to fix

agent_reports/S8_refute.md § S8-R4 — S8-F6's evidence quotes a class that does not exist in the codebase, and its fix targets the wrong construct: replace the two static const fields with an instance field built from the I18n the Backstop is constructed with, and pass _details at every call site. Accept that installs which already created the channel keep the English one until the id changes.

How to prove it

agent_reports/S8_refute.md § S8-R4 — S8-F6's evidence quotes a class that does not exist in the codebase, and its fix targets the wrong construct: a test constructing Backstop with I18n('fr') and asserting the details' channel name is the French string. Red today — the field is static const, so no test can vary it, which is the one part of S8-F6 that is exactly right.

S9-F08 · `ITSAppUsesNonExemptEncryption` is absent, forcing the export-compliance questionnaire on every single upload

What is wrong

ITSAppUsesNonExemptEncryption is absent, forcing the export-compliance questionnaire on every single upload

File and line

ios/Runner/Info.plist:4-69 — key absent from the top-level <dict> at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S9_platform_config.md · agent_reports/S9_refute.md. The refuter proved the absence independently with its own Info.plist key-set diff.

findings/S9_platform_config.md § S9-F08 — ITSAppUsesNonExemptEncryption is absent, forcing the export-compliance questionnaire on every single upload: $ grep -c ITSAppUsesNonExemptEncryption ios/Runner/Info.plist 0 Apple's documentation for the key (https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption, retrieved 2026-08-04, captures/apple_itsappusesnonexemptencryption.txt), verbatim: > Set the value for this key to NO in your app's Information Property List file to indicate that > your app—including any third-party libraries you link against—either uses no encryption, or only > uses encryption that's exempt from export compliance requirements […] > > If you don't have the ITSAppUsesNonExemptEncryption key in your app's Info.plist file, App Store > Connect walks you through an export compliance questionnaire every time you upload a new version > of your app. Including the key streamlines the app submission process.

What to fix

findings/S9_platform_config.md § S9-F08 — ITSAppUsesNonExemptEncryption is absent, forcing the export-compliance questionnaire on every single upload: Add to ios/Runner/Info.plist: xml <key>ITSAppUsesNonExemptEncryption</key> <false/> This claim must be re-checked if any networking or crypto dependency is ever added.

How to prove it

findings/S9_platform_config.md § S9-F08 — ITSAppUsesNonExemptEncryption is absent, forcing the export-compliance questionnaire on every single upload: /usr/libexec/PlistBuddy -c "Print :ITSAppUsesNonExemptEncryption" ios/Runner/Info.plist prints false. Red now (Does Not Exist), green after. ---

S9-F09 + S13-F3 (+ Apple readiness C1) · No `PrivacyInfo.xcprivacy` in the app bundle

What is wrong

No PrivacyInfo.xcprivacy in the app bundle

File and line

Repository-wide — no .xcprivacy file exists. S13 cited the calling code at lib/journal.dart:69-72, :170-171, :194-199, :226, :230 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S9_platform_config.md · findings/S13_data.md · store_readiness/02_apple_app_store.md C1 · agent_reports/S9_refute.md §Contrarian A · agent_reports/S13_refute.md §1. Two refuters settled this independently, and they converged. Both quoted Apple's operative sentence, which no stream quoted: "For each executable or dynamic library in an app that uses a required reason API, the bundle that includes the executable or dynamic library needs to include a privacy manifest file that reports the API." S9's refuter enumerated nine linked iOS plugins from .flutter-plugins-dependencies (S9's own table had missed package_info_plus 10.2.1, transitive via wakelock_plus) and read the Flutter 3.44.8 engine manifest out of the built artifact: FileTimestamp (0A2A.1, C617.1), SystemBootTime (35F9.1). S13's refuter went further and disassembled the release build: libapp.so (Cadence's own AOT Dart, every File.exists()/length()/copy()/writeAsString() in journal.dart) has 0 undefined dynamic symbols and no required-reason symbols; the stat family lives entirely in libflutter.so, and nm -u Flutter.framework/Flutter shows _fstat, _lstat, _stat. Phase 4 can add an empty manifest (NSPrivacyTracking = false, empty arrays) — it must NOT populate NSPrivacyAccessedAPITypes, because Apple binds you to what you declare. Full resolution in CONTRADICTIONS.md §1.

findings/S13_data.md § S13-F3 — The app ships no PrivacyInfo.xcprivacy, and its own code calls required-reason APIs: Apple's requirement, verbatim from the capture proof/03_market/captures/s13_apple_required_reason_api.txt (URL https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api, retrieved 2026-08-04): > If you upload an app to App Store Connect that uses required reason API without describing the > reason in its privacy manifest file, Apple sends you an email reminding you to add the reason to > the app's privacy manifest. Starting can 1, 2024, apps that don't describe their use of required > reason API in their privacy manifest file aren't accepted by App Store Connect. > > … If you use the API in your app's code, then you need to report the API in your app's privacy > manifest file. … Your third-party SDK can't rely on the privacy manifest files for apps that link > the third-party SDK. Per-pod manifest inventory, measured: | Pod | Ships PrivacyInfo.xcprivacy? | |---|---| | shared_preferences_foundation-2.5.6 | Yes — declares NSPrivacyAccessedAPICategoryUserDefaults, reason 1C8F.1 | | share_plus-13.3.0 | Yes | | wakelock_plus-1.7.0 | Yes | | package_info_plus-10.2.1 | Yes | | vibration-3.2.0 | Yes | | flutter_local_notifications-22.1.0 | Yes | | device_info_plus-13.2.0 | Yes — and it declares nothing: empty NSPrivacyAccessedAPITypes, empty NSPrivacyCollectedDataTypes, NSPrivacyTracking false | | path_provider_foundation-2.6.0 | No | | audioplayers_darwin-6.5.0 | No | | the Cadence app itself | No | findings/S9_platform_config.md § S9-F09 — No PrivacyInfo.xcprivacy in the app bundle; not required today, and the precise reason it is not required is undocumented anywhere in the repo: No manifest anywhere: $ find . -name "*.xcprivacy" -not -path "./.git/*" (no output) The app's own Swift touches no required-reason API: $ grep -nE "UserDefaults|systemUptime|mach_absolute|statfs|volumeAvailableCapacity|modificationDate|creationDate|activeInputModes|stat\(" ios/Runner/*.swift ios/RunnerTests/*.swift (no output) Per-dependency manifest coverage, resolved versions from pubspec.lock, read out of the pub cache: | Darwin component | Ships PrivacyInfo.xcprivacy? | Declares | |---|---|---| | Flutter engine 3.44.8 (Flutter.xcframework) | yes | FileTimestamp (0A2A.1, C617.1), SystemBootTime (35F9.1) | | shared_preferences_foundation 2.5.6 | yes | UserDefaults (1C8F.1) | | flutter_local_notifications 22.1.0 | yes | UserDefaults (CA92.1) | | share_plus 13.3.0 | yes | empty NSPrivacyAccessedAPITypes | | device_info_plus 13.2.0 | yes | empty NSPrivacyAccessedAPITypes | | vibration 3.2.0 | yes | — | | wakelock_plus 1.7.0 | yes | — | | path_provider_foundation 2.6.0 | no native bundle at all | pure-Dart FFI plugin (dartPluginClass: PathProviderFoundation), no covered API in its sources | | audioplayers_darwin 6.5.0 | no | grep over its Darwin sources returns no covered API | The grep behind the last two rows: $ grep -rnE "UserDefaults|systemUptime|mach_absolute|statfs|volumeAvailableCapacity|modificationDate|creationDate|NSSearchPath|\.urls\(for" \ ~/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/darwin/ \ ~/.pub-cache/hosted/pub.dev/audioplayers_darwin-6.5.0/darwin/ (no output) Note the one Dart-side call that could have mattered — lib/journal.dart:72, if (await f.length() > _maxBytes), which reaches stat — is covered by the Flutter engine's own manifest, which is exactly why the engine declares FileTimestamp. The rule this is judged against (https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api, retrieved 2026-08-04, captures/apple_required_reason_api.txt), verbatim: > If you upload an app to App Store Connect that uses required reason API without describing the > reason in its privacy manifest file, Apple sends you an email reminding you to add the reason to > the app's privacy manifest. Starting can 1, 2024, apps that don't describe their use of required > reason API in their privacy manifest file aren't accepted by App Store Connect. And the ownership rule that makes per-dependency manifests sufficient (same page), verbatim: > If you use the API in your app's code, then you need to report the API in your app's privacy > manifest file. If you use the API in your third-party SDK's code, then you need to report the API > in your third-party SDK's privacy manifest file. Static analysis only — no Xcode, so this is not confirmed against a generated privacy report.

What to fix

findings/S13_data.md § S13-F3 — The app ships no PrivacyInfo.xcprivacy, and its own code calls required-reason APIs: compliance plumbing, explicitly in scope under R6. Add ios/Runner/PrivacyInfo.xcprivacy to the Runner target declaring NSPrivacyAccessedAPICategoryFileTimestamp and NSPrivacyAccessedAPICategoryDiskSpace with the reason codes matching their use (the journal reads its own file's size and timestamps), plus NSPrivacyTracking false, empty NSPrivacyTrackingDomains, and the collected-data types from §1. Separately, raise the two missing pod manifests upstream or pin versions that ship them. findings/S9_platform_config.md § S9-F09 — No PrivacyInfo.xcprivacy in the app bundle; not required today, and the precise reason it is not required is undocumented anywhere in the repo: Add ios/Runner/PrivacyInfo.xcprivacy to the Runner target with NSPrivacyTracking = false, empty NSPrivacyTrackingDomains, empty NSPrivacyCollectedDataTypes (Cadence collects nothing — everything is on-device SharedPreferences and a local journal file), and an empty NSPrivacyAccessedAPITypes array. Declaring it explicitly costs nothing, makes the Xcode privacy report meaningful, and turns a fragile implicit verdict into a stated one.

How to prove it

findings/S13_data.md § S13-F3 — The app ships no PrivacyInfo.xcprivacy, and its own code calls required-reason APIs: the current definitive test is find ios -name 'PrivacyInfo.xcprivacy' returning nothing — red today, green after. The submission-level test needs an artifact this workspace does not have: a Mac with Xcode, to run xcodebuild -exportArchive and upload to App Store Connect. That is the exact missing artifact and the exact test that would settle it beyond the file's existence. --- findings/S9_platform_config.md § S9-F09 — No PrivacyInfo.xcprivacy in the app bundle; not required today, and the precise reason it is not required is undocumented anywhere in the repo: plutil -lint ios/Runner/PrivacyInfo.xcprivacy exits 0 and /usr/libexec/PlistBuddy -c "Print :NSPrivacyTracking" ios/Runner/PrivacyInfo.xcprivacy prints false. Red now (file does not exist), green after. ---

S9R-M03 · The app localises itself manually into French and English, but the iOS bundle declares English only

What is wrong

The app localises itself manually into French and English, but the iOS bundle declares English only

File and line

ios/Runner/Info.plist:4-69 (CFBundleLocalizations absent) and ios/Runner.xcodeproj/project.pbxproj:196-201 (developmentRegion = en) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S9_refute.md §Part 6. lib/i18n.dart carries full fr and en string tables, lib/ui/modals.dart:626 offers the picker, and lib/engine/store.dart:293-294 starts a fresh install in French when the tablet is French. None of that is declared to iOS. Companion to S8-F1/S14R-M2 (the Flutter-side localisation gap) and to S8-F5 (the name is inconsistent across the four places an OS reads it).

agent_reports/S9_refute.md § S9R-M03 — The app localises itself manually into French and English, but the iOS bundle declares English only: Apple's definition of the key, from my capture captures/apple_cfbundlelocalizations.txt (https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundlelocalizations, retrieved 2026-08-04T11:55Z), verbatim: > CFBundleLocalizations — The localizations handled manually by your app. […] Type: Array of > strings. Attributes — Default: en The declaration side, verbatim from ios/Runner.xcodeproj/project.pbxproj:196-201: developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); $ grep -c CFBundleLocalizations ios/Runner/Info.plist 0 $ find ios -name "*.lproj" -type d ios/Runner/Base.lproj The behaviour it contradicts, verbatim from lib/engine/store.dart:293-294: dart static String seedLangFor(String? deviceLang) => deviceLang?.toLowerCase().startsWith('fr') == true ? 'fr' : 'en'; fed at lib/engine/store.dart:347: dart deviceLang ?? PlatformDispatcher.instance.locale.languageCode); And the corroborating structural fact: the complete key set of ios/Runner/Info.plist is identical to the Flutter 3.44.8 template's (diff of the sorted <key> sets against templates/app/ios.tmpl/Runner/Info.plist.tmpl produces no output), so no localisation key was ever considered.

What to fix

agent_reports/S9_refute.md § S9R-M03 — The app localises itself manually into French and English, but the iOS bundle declares English only: Add to ios/Runner/Info.plist, inside the top-level <dict>: xml <key>CFBundleLocalizations</key> <array> <string>en</string> <string>fr</string> </array> and add fr to knownRegions in the project file. Compliance plumbing for an already-shipped capability (R6): the French strings exist; the declaration makes the platform aware of them.

How to prove it

agent_reports/S9_refute.md § S9R-M03 — The app localises itself manually into French and English, but the iOS bundle declares English only: Runnable today without Xcode: /usr/libexec/PlistBuddy -c "Print :CFBundleLocalizations" ios/Runner/Info.plist must list en and fr. Red now (Does Not Exist), green after. The precise on-device test that settles the runtime half, which cannot be run on this machine (no Xcode): build to an iPad whose system language is French, log PlatformDispatcher.instance.locale.languageCode at first launch, and assert it is fr. If it reports en before the fix and fr after, the severity is HIGH, not MEDIUM; if it reports fr in both cases, the declaration gap is real but its only cost is the store-facing language metadata, and MEDIUM is right. I do not have the artifact — an Xcode installation and an iPad — to run it, and I do not guess the outcome. ---

M3 (S10's refuter) · The dish name is silently capped at 24 characters with the counter switched off

What is wrong

The dish name is silently capped at 24 characters with the counter switched off

File and line

lib/ui/modals.dart:251-252 (maxLength: 24 with .copyWith(counterText: '')) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S10_refute.md §6. The field stops accepting keystrokes at 24 characters and shows nothing to say why. The cap is not a layout constraint — the tile deliberately auto-shrinks long names rather than truncating (tile.dart:393-395, "un nom coupé ne sert à rien en cuisine"), so the display would have absorbed a longer name. Complements S4-F02 (the shrink has no floor).

agent_reports/S10_refute.md § M3 — The dish name is silently capped at 24 characters with the counter switched off: dart // lib/ui/modals.dart:249-252 TextField( controller: _name, maxLength: 24, decoration: _inputDeco(hint: tr('namePh')).copyWith(counterText: ''), The announcement field beside it is capped at 60 with the counter equally blanked (modals.dart:336-337). agent_reports/S14_refute.md § S14R-M3 — S14 audited a 47-file scope that was deleted from the plan before Phase 1 launched, so five of its findings belong to other streams and its partition reconciliation contradicts the partition tool: proof/01_findings/S14_refute/check_partition_rerun.txt (✅ PASS — partition is total, 144 tracked, 0 unowned) and proof/01_findings/S14_refute/partition_ownership_diff.txt: S14 self-assigned : 47 canonical S14 files : 3 FILES S14 CLAIMED WHOSE CANONICAL PRIMARY IS ANOTHER STREAM: 44 by true owner: {'S11': 17, 'S9': 16, 'S6': 9, 'S10': 1, 'S7': 1} and, verbatim from tools/check_partition.py: python # --- S14 · app entry and startup ordering (named files only — no catch-all) ------------------ Claim("lib/main.dart", "S14", "58 lines, 0.00% covered, owns the startup sequence and the wakelock failure path"), Claim("ios/Runner/SceneDelegate.swift", "S14", "the iOS scene lifecycle entry point, the counterpart of main.dart"), Claim("analysis_options.yaml", "S14", "the static-analysis configuration the whole toolchain boots from, named to S14 in PLAN.md §4"), agent_reports/S2_refute.md § S2R-M3 — A load- critical banner can never be cleared for the life of the session: whole-tree grep — $ grep -rn "clearCritical" lib/ lib/alarm_backstop.dart:91: Diag.clearCritical('backstop-init'); lib/alarm_backstop.dart:202: Diag.clearCritical('backstop-schedule'); lib/alarm_backstop.dart:203: if (_exactOk) Diag.clearCritical('backstop-exact'); lib/diagnostics.dart:41: static void clearCritical(String scope) { lib/audio/voice.dart:57: Diag.clearCritical('voice-init'); lib/audio/audio.dart:74: if (critical) Diag.clearCritical('audio-play'); lib/engine/store.dart:139: Diag.clearCritical('save-$key'); and behaviourally: M3 after a full healthy rewrite the banner is still: {load-cadence-timers-v1}

What to fix

agent_reports/S10_refute.md § M3 — The dish name is silently capped at 24 characters with the counter switched off: show the counter (drop counterText: '') or raise the cap. Either is a one-token change; which one is Serge's call. agent_reports/S14_refute.md § S14R-M3 — S14 audited a 47-file scope that was deleted from the plan before Phase 1 launched, so five of its findings belong to other streams and its partition reconciliation contradicts the partition tool: (1) re-title findings/S14_entry_unowned.md and restate its scope as the three canonical files; (2) move F6 → S10, F7 → S6, F8 → S9, F9 → S9, F11 → S7, keeping the evidence verbatim, and have each owning stream reconcile against its own findings rather than accept them wholesale; (3) delete the git ls-files reconciliation section from the S14 file — partition.txt produced by tools/check_partition.py is the artefact that settles this, and a second, divergent copy of it is worse than none; (4) keep F1–F5 and F10 under S14, since lib/main.dart and analysis_options.yaml are genuinely S14's. agent_reports/S2_refute.md § S2R-M3 — A load- critical banner can never be cleared for the life of the session: call Diag.clearCritical('load-$key') from the success path of _write alongside the existing Diag.clearCritical('save-$key') at store.dart:139 — a successful write to a key is proof that key is healthy again.

How to prove it

agent_reports/S10_refute.md § M3 — The dish name is silently capped at 24 characters with the counter switched off: a widget test entering 30 characters and asserting the controller holds 30 — red today. agent_reports/S14_refute.md § S14R-M3 — S14 audited a 47-file scope that was deleted from the plan before Phase 1 launched, so five of its findings belong to other streams and its partition reconciliation contradicts the partition tool: a per-finding owner column in findings/S14_entry_unowned.md that matches awk over proof/00_baseline/partition.txt for each finding's Location: path. It disagrees on five findings today. --- agent_reports/S2_refute.md § S2R-M3 — A load- critical banner can never be cleared for the life of the session: my test "M3 — a load- critical banner can never be cleared…". Invert the final expect(Diag.critical.value, contains('load-cadence-timers-v1')) to isNot(contains(…)). Red now, green after.

MISS-3 (S1's refuter) · One user action issues two full persist cycles, and one beat issues one per firing dish

What is wrong

One user action issues two full persist cycles, and one beat issues one per firing dish

File and line

lib/engine/engine.dart:212 and :414 (delete), :212 and :403 (save), :285 (per-entry in tick) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S1_refute.md §5. Three dishes landing on the same 150 ms beat produce three full persist cycles.

agent_reports/S1_refute.md § MISS-3 — one user action issues two full persist cycles, and one beat issues one per firing dish: proof/01_findings/S1_refute/redundant_persist_run.txt, three probes, all PASS, source at s1r_persist_test.dart: 00:00 +0: R6a deleteDef issues persistRun TWICE for one user action 00:00 +1: R6b saveDef on a def with batches issues persistRun TWICE 00:00 +2: R6c three dishes firing on ONE beat issue three separate persistRun 00:00 +3: All tests passed! dart final before = h.persistRuns; e.deleteDef('p'); expect(h.persistRuns - before, 2, reason: 'removeClonesOf (engine.dart:212) then deleteDef (engine.dart:414)');

What to fix

agent_reports/S1_refute.md § MISS-3 — one user action issues two full persist cycles, and one beat issues one per firing dish: make removeClonesOf a pure state mutation that returns whether it changed anything, and let each caller issue exactly one host.persistRun()/host.persistClones(); in tick(), accumulate a _dirty flag and persist once after the loop.

How to prove it

agent_reports/S1_refute.md § MISS-3 — one user action issues two full persist cycles, and one beat issues one per firing dish: the three probes above, inverted to expect 1, 1 and 1. Red today (they measure 2, 2, 3), green after. ---

R-F4 (S11's refuter) · Citation defects in S11: three `pubspec.yaml` line references

What is wrong

Citation defects in S11: three pubspec.yaml line references

File and line

S11 cites pubspec.yaml:33-34 for the icon-origin comment (used 4×) — actual :31-32; S11 cites :36 for adaptive_icon_background — actual :37 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S11_refute.md §7. Full cat -n pubspec.yaml (68 lines) recorded. Correct before publishing; pubspec.yaml:33 is flutter_launcher_icons: and :34 is android: true.

agent_reports/S11_refute.md § R-F4 — Citation defects in S11 (three pubspec.yaml line references): R22, full cat -n pubspec.yaml (68 lines): | S11 cites | Actual | What is at the cited line | |---|---|---| | pubspec.yaml:33-34 — icon origin comment (used 4×) | 31-32 | 33 is flutter_launcher_icons:, 34 is android: true | | pubspec.yaml:36adaptive_icon_background: "#F4EFE4" | 37 | 36 is image_path: "assets/icon/ic_legacy.png" | | pubspec.yaml:48-69 — font block (used 3×, incl. the draft) | 48-68 | line 69 does not exist | All other references check out: pubspec.yaml:48-64 ✓, README.md:38 ✓, lib/main.dart:20 ✓, lib/audio/audio.dart:89 and :92 ✓, lib/ui/logo.dart:13 ✓, tools/build_ringtones.py:2 ✓ and 238 lines ✓, lib/i18n.dart settingsTitle at 63 and 111 ✓.

What to fix

Implement the correction stated in agent_reports/S11_refute.md §7; do not change the pinned evidence location S11 citespubspec.yaml:33-34for the icon-origin comment (used 4×) — actual **:31-32**; S11 cites:36foradaptive_icon_background— actual **:37** without updating the regression citation.

How to prove it

Add a regression named R-F4 (S11's refuter) that reproduces “Citation defects in S11: three pubspec.yaml line references” at 03a176e72ef0075eec86b8915cbe6e93042a3b9d and asserts the opposite after the fix; run it in the whole suite under the mutation-specificity gate in §8.

R-F5 (S11's refuter) · The NOTICES character count is method-dependent

What is wrong

The NOTICES character count is method-dependent

File and line

NOTICES — S11 and the draft both publish 1,381,653; reading the same bytes without newline translation gives 1,381,705 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S11_refute.md §7. The file contains exactly 52 CRLF pairs; S11 read it in text mode, which collapsed them. Neither figure is wrong, but a digit-bearing claim under R1 must say which measurement it is.

agent_reports/S11_refute.md § R-F5 — The NOTICES character count is method-dependent: bytes-decoded chars 1381705 CRLF count 52 newline-translated chars 1381653 (R10 and the follow-up check).

What to fix

Implement the correction stated in agent_reports/S11_refute.md §7; do not change the pinned evidence location NOTICES` — S11 and the draft both publish `1,381,653`; reading the same bytes without newline translation gives `1,381,705 without updating the regression citation.

How to prove it

Add a regression named R-F5 (S11's refuter) that reproduces “The NOTICES character count is method-dependent” at 03a176e72ef0075eec86b8915cbe6e93042a3b9d and asserts the opposite after the fix; run it in the whole suite under the mutation-specificity gate in §8.

R-F6 (S11's refuter) · `assets/icon/*.png` are build inputs, not shipped bundle assets

What is wrong

assets/icon/*.png are build inputs, not shipped bundle assets

File and line

pubspec.yaml:44-46 lists only assets/audio/ and assets/logo/ at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S11_refute.md §7. The three assets/icon/ PNGs are inputs to flutter_launcher_icons and reach the APK only as Android resources. No licence consequence — they are author-original — but the distinction matters for any claim about what is "distributed".

agent_reports/S11_refute.md § R-F6 — assets/icon/*.png are build inputs, not shipped bundle assets: R27, APK bundle listing shows assets/flutter_assets/assets/{audio,fonts}/… and assets/flutter_assets/assets/logo/mark_white.png, and no assets/icon/ entries.

What to fix

Implement the correction stated in agent_reports/S11_refute.md §7; do not change the pinned evidence location pubspec.yaml:44-46` lists only `assets/audio/` and `assets/logo/ without updating the regression citation.

How to prove it

Add a regression named R-F6 (S11's refuter) that reproduces “assets/icon/*.png are build inputs, not shipped bundle assets” at 03a176e72ef0075eec86b8915cbe6e93042a3b9d and asserts the opposite after the fix; run it in the whole suite under the mutation-specificity gate in §8.

R-F7 (S11's refuter) · DSEG's `name` ID 0 is not a copyright statement

What is wrong

DSEG's name ID 0 is not a copyright statement

File and line

assets/fonts/DSEG7Classic-Bold.ttf, name ID 0 (Created by Keshikan\nwith FontForge 2.0…); the copyright statement is in ID 13 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S11_refute.md §7. S11 §2.4 asserts "All seven binaries … name a copyright holder". The conclusion holds; the supporting statement does not, per-record.

agent_reports/S11_refute.md § R-F7 — DSEG's name ID 0 is not a copyright statement: R05. ---

What to fix

Implement the correction stated in agent_reports/S11_refute.md §7; do not change the pinned evidence location `assets/fonts/DSEG7Classic-Bold.ttf, name ID 0 (Created by Keshikan\nwith FontForge 2.0…); the copyright statement is in ID 13` without updating the regression citation.

How to prove it

Add a regression named R-F7 (S11's refuter) that reproduces “DSEG's name ID 0 is not a copyright statement” at 03a176e72ef0075eec86b8915cbe6e93042a3b9d and asserts the opposite after the fix; run it in the whole suite under the mutation-specificity gate in §8.

S1-F11 · `reorder` is asymmetric: dragging forward drops after the target, dragging back drops before it

What is wrong

reorder is asymmetric: dragging forward drops after the target, dragging back drops before it

File and line

lib/engine/engine.dart:418-426, quoted lines :422-423 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S1_engine.md · agent_reports/S1_refute.md. Probe measures [a,b,c] → reorder('a','c') → [b,c,a] and reorder('c','a') → [c,a,b]. test/engine_test.dart:335-339 asserts only the first direction.

findings/S1_engine.md § S1-F11 — reorder is asymmetric: dragging forward drops after the target, dragging back drops before it: proof/01_findings/S1/facts_run.txt, probe J1 and defects_run.txt::S1-F11: S1-F11 reorder must put the dragged tile at the target index [E] Expected: ['b', 'c', 'a'] Actual: ['c', 'a', 'b'] dropping c on a must mirror dropping a on c From [a, b, c]: reorder('a','c') gives [b, c, a] (a lands after c) while reorder('c','a') gives [c, a, b] (c lands before a). test/engine_test.dart:335-339 asserts the first case and never the second, so the asymmetry is codified as correct.

What to fix

findings/S1_engine.md § S1-F11 — reorder is asymmetric: dragging forward drops after the target, dragging back drops before it: timers.insert(from < to ? to - 1 : to, m); if the intent is "swap into the target's slot", or leave insert(to, m) and have the caller pass the post-removal index. Either is fine; what is not fine is the two directions disagreeing.

How to prove it

findings/S1_engine.md § S1-F11 — reorder is asymmetric: dragging forward drops after the target, dragging back drops before it: s1_defects_test.dart::S1-F11 — currently red. It asserts that reorder('c','a') on [a,b,c] produces the mirror of reorder('a','c'). ---

S1-F12 · `spawnClone` will start a batch of a dish that is not running

What is wrong

spawnClone will start a batch of a dish that is not running

File and line

lib/engine/engine.dart:191-200 — parent check at :192-193, cap check at :194, no run check; startTimer at :198 runs regardless; contract at :189 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S1_engine.md · agent_reports/S1_refute.md. Reproduced.

findings/S1_engine.md § S1-F12 — spawnClone will start a batch of a dish that is not running: proof/01_findings/S1/facts_run.txt, probe D5, and defects_run.txt::S1-F12: S1-F12 spawnClone must refuse a dish that is not running [E] Expected: null Actual: 'qcrsqrdx407q' The related state is legitimate and separately proven: probe K7 shows that stopping a parent that already has batches leaves those batches running and that reconcile() deliberately keeps them, so the fix must guard spawnClone only, not reconcile.

What to fix

findings/S1_engine.md § S1-F12 — spawnClone will start a batch of a dish that is not running: if (!run.containsKey(pid)) return null; after the null check at :193. The caller already handles a null return (it is the cap path).

How to prove it

findings/S1_engine.md § S1-F12 — spawnClone will start a batch of a dish that is not running: s1_defects_test.dart::S1-F12 — currently red. It requires e.spawnClone('p') to return null for a definition that was never started. ---

S10-F13 · Below 150 px of tile width the app removes its own touch-target floor

What is wrong

Below 150 px of tile width the app removes its own touch-target floor

File and line

lib/ui/tile.dart:589-597 (S10 cited :565-597) sets minH = 0 in the bottom tier; button geometry at :678 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S10_product.md · agent_reports/S10_refute.md. Geometry replicated independently: 800×540 crosses 150 px at n=21 (tile 127×108) and 1280×740 at n=49 (tile 136×116); the pinned 4-dish case reproduces exactly (cols 2, tileW 602, rowH 332, gap 21, pad 27). S10's "roughly 37 px" is optimistic: ±10 s measures 35 px and the ✕ 31.6 px including hit padding. Corroborates S4-F03.

findings/S10_product.md § S10-F13 — Below 150 px of tile width the app removes its own touch-target floor: measured board geometry, proof/01_findings/S10/grid_capacity.txt (arithmetic replication of grid_layout.dart:62-108, validated against the pinned 602 × 332 case). Guidance: "evaluate making sure these elements have a width and height of at least 48dp" — https://support.google.com/accessibility/android/answer/7101858?hl=en, retrieved 2026-08-04, capture at proof/01_findings/S10/captures/android_touch_target_size.txt.

What to fix

findings/S10_product.md § S10-F13 — Below 150 px of tile width the app removes its own touch-target floor: give the bottom tier a floor, as the two tiers above it already have. Defect repair.

How to prove it

findings/S10_product.md § S10-F13 — Below 150 px of tile width the app removes its own touch-target floor: a test asserting the computed button height at w = 140 is ≥ the floor — red today, since the branch sets minH = 0.

S11-F6 · Big Shoulders Display has been retired from Google Fonts, so the bundled version cannot be re-fetched from the family page

What is wrong

Big Shoulders Display has been retired from Google Fonts, so the bundled version cannot be re-fetched from the family page

File and line

pubspec.yaml:48-56; assets/fonts/BigShouldersDisplay-*.ttf at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S11_asset_licensing.md · agent_reports/S11_refute.md §2.1. ofl/bigshouldersdisplay still carries the family and the licence; the variable master re-instances to the bundled binaries. Provenance is recoverable.

findings/S11_asset_licensing.md § S11-F6 — Big Shoulders Display has been retired from Google Fonts, so the bundled version cannot be re-fetched from the family page: proof/01_findings/S11/08_bigshoulders_upstream.txt: --- fonts.google.com no longer serves the family: )]}' { "error": "Unable to find family: Big Shoulders Display" } --- google/fonts still carries it, and a successor family exists: name: "Big Shoulders Display" ... date_added: "2019-09-11" name: "Big Shoulders" ... date_added: "2025-02-06" (retrieved 2026-08-04)

What to fix

findings/S11_asset_licensing.md § S11-F6 — Big Shoulders Display has been retired from Google Fonts, so the bundled version cannot be re-fetched from the family page: record the exact provenance in LICENSES.md — family, version 2.002, source github.com/google/fonts/tree/main/ofl/bigshouldersdisplay, per-file SHA-256 — which the draft already does. No code change.

How to prove it

findings/S11_asset_licensing.md § S11-F6 — Big Shoulders Display has been retired from Google Fonts, so the bundled version cannot be re-fetched from the family page: grep -c 'ofl/bigshouldersdisplay' LICENSES.md returns 0 today and non-zero after. ---

S12-F1 · Unmounting while `_boot()` is awaiting orphans the 150 ms ticker permanently

What is wrong

Unmounting while _boot() is awaiting orphans the 150 ms ticker permanently

File and line

lib/ui/home.dart:154 (creation; the reproduction binding names home.dart:154:21), :236-243 (dispose), :136, :141, :148, :149 (the awaits it sits behind) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S12_lifecycle.md · agent_reports/S12_refute.md §2 · proof/01_findings/S12_refute/repro_s12_w7_boot_race.txt. Reproduced red (EXIT_CODE=1). HIGH rested on "two ticks running concurrently … a battery that drains twice as fast", and S12's own grep proves that cannot happen at this commit: one HomeScreen construction site (main.dart:55), one route, no full-screen push, showDialog pushes over without disposing, and AndroidManifest.xml:31's configChanges means no ordinary configuration change recreates the activity. _HomeScreenState.dispose() has exactly one production trigger and it destroys the isolate the orphan lives in.

findings/S12_lifecycle.md § S12-F1 — Unmounting while _boot() is awaiting orphans the 150 ms ticker permanently: recorded run proof/01_findings/S12/w7_boot_race.txt. The test holds cancelAll (the last await, lib/alarm_backstop.dart:89) open for 600 ms, unmounts, then lets the reply land: === S12 W7 — unmount while _boot() is still awaiting === platform calls completed before unmount: 26 platform calls after the disposed boot finished: 26 ... Timer (duration: 0:00:00.150000, periodic: true), created: #5 _HomeScreenState._boot (package:cadence/ui/home.dart:154:21) A Timer is still pending even after the widget tree was disposed. 'package:flutter_test/src/binding.dart': Failed assertion: line 2542 pos 12: '!timersPending' EXIT_CODE=1 The complementary case is green: when the boot completes before the unmount, no timer survives — proof/01_findings/S12/w2_dispose.txt and w3_tap_window.txt, both EXIT_CODE=0.

What to fix

findings/S12_lifecycle.md § S12-F1 — Unmounting while _boot() is awaiting orphans the 150 ms ticker permanently: guard the assignment, not just the cancel: dart if (!mounted) return; // immediately before line 153 _lastTickMs = now(); _ticker = Timer.periodic(...); and make dispose() set a bool _disposed = true that _boot() re-checks after every await.

How to prove it

findings/S12_lifecycle.md § S12-F1 — Unmounting while _boot() is awaiting orphans the 150 ms ticker permanently: proof/01_findings/S12/tests/s12_w7_boot_race_test.dart, test "W7 — unmount mid-boot then let _boot() finish". Red now (EXIT_CODE=1, assertion above), green after. ---

S12-F3 · `Backstop` owns a 300 ms timer but has no disposal, and arms an OS alarm after teardown

What is wrong

Backstop owns a 300 ms timer but has no disposal, and arms an OS alarm after teardown

File and line

lib/alarm_backstop.dart:41 (field), :146-147 (creation); no disposal member exists at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S12_lifecycle.md · agent_reports/S12_refute.md §2.1. The missing disposal member is confirmed. The harm claim is backwards: the alarm armed after teardown lands at the operator's new deadline for a timer that is still running and already persisted — the safety net doing its job at the moment it matters most. alarm_backstop.dart:125-126 warns about arming for a stopped timer; this is not that case, and Backstop.init() calls _plugin.cancelAll() at :89 on the next launch. Its proposed fix actively harms the product — see S12R-F3.

findings/S12_lifecycle.md § S12-F3 — Backstop owns a 300 ms timer but has no disposal, and arms an OS alarm after teardown: recorded run proof/01_findings/S12/w6_backstop_debounce.txt — one -10 press, then unmount 50 ms later: === S12 W6 — unmount inside the 300 ms backstop debounce === zonedSchedule so far: 1 zonedSchedule after unmount+600ms: 2 and, once S12-F2 is fixed, the same timer is what the binding then reports as pending (proof/01_findings/S12/w0_AFTER_fix_green.txt intermediate run): Timer (duration: 0:00:00.300000, periodic: false), created: #5 Backstop.sync (package:cadence/alarm_backstop.dart:147:11) #6 _HomeScreenState.persistRun (package:cadence/ui/home.dart:265:14) #7 Engine.adjustTimer (package:cadence/engine/engine.dart:239:10)

What to fix

CORRECTED FIX: disposal must first call _flushSchedules(), then cancel and clear _debounce and _pending; never discard a committed deadline update. RED/GREEN TEST: change a deadline by ten seconds, dispose inside the 300 ms debounce window, and assert the OS request holds the new deadline, not the stale one.

How to prove it

findings/S12_lifecycle.md § S12-F3 — Backstop owns a 300 ms timer but has no disposal, and arms an OS alarm after teardown: run s12_w0_ticker_live_test.dart with the S12-F2 fix already applied — it is red on the 300 ms Backstop.sync timer and green once backstop.dispose() is added (w0_AFTER_fix_green.txt, EXIT_CODE=0). ---

S12-F4 · `Journal.init()` overwrites its two periodic timers without cancelling the previous pair

What is wrong

Journal.init() overwrites its two periodic timers without cancelling the previous pair

File and line

lib/journal.dart:106-107; the only cancel site is :241-242, inside a @visibleForTesting member at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S12_lifecycle.md · agent_reports/S12_refute.md §2. Reproduced (SOAK A5: timers created: 4 … STILL-LIVE periodic timers: 2). main() is the only caller and an activity recreation gives a fresh isolate; the only in-isolate second init() is a debug hot restart.

findings/S12_lifecycle.md § S12-F4 — Journal.init() overwrites its two periodic timers without cancelling the previous pair: verbatim source — dart // lib/journal.dart:106-107 _beat = Timer.periodic(const Duration(seconds: 60), (_) => _heartbeat()); _autoFlush = Timer.periodic(const Duration(seconds: 3), (_) => _flush()); dart // lib/journal.dart:239-242 — the ONLY cancel site in the file @visibleForTesting static void disableForTests() { _beat?.cancel(); _autoFlush?.cancel(); Measured with a Zone that records every timer created and every cancel() call (proof/01_findings/S12/tests/s12_timer_probe.dart). Recorded run proof/01_findings/S12/soak_engine_journal.txt: === S12 SOAK A5 — Journal.init x2, then disableForTests() === timers created: 4 (periodic 4, one-shot 0) cancel() calls: 2 STILL-LIVE periodic timers: 2 [periodic 60000ms, periodic 3000ms]

What to fix

findings/S12_lifecycle.md § S12-F4 — Journal.init() overwrites its two periodic timers without cancelling the previous pair: two lines at the top of init(): dart _beat?.cancel(); _autoFlush?.cancel(); and rename disableForTests to a real shutdown() that production can call.

How to prove it

findings/S12_lifecycle.md § S12-F4 — Journal.init() overwrites its two periodic timers without cancelling the previous pair: proof/01_findings/S12/tests/s12_soak_test.dart, test "SOAK A5 — Journal.init() leaks its periodic timers when re-run". Change its expectation from expect(tracker.livePeriodic.length, 2) to 0 — red now, green after. ---

S12R-F2 · `Journal.snapshot` is a static closure over `_HomeScreenState` that is never cleared, so a torn-down screen and its five `AudioPlayer`s are retained for the life of the isolate

What is wrong

Journal.snapshot is a static closure over _HomeScreenState that is never cleared, so a torn-down screen and its five AudioPlayers are retained for the life of the isolate

File and line

lib/ui/home.dart:93-103 (set), lib/journal.dart:40 (the static field), lib/ui/home.dart:236-243 (dispose, which does not clear it) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S12_refute.md §5. The retention mechanism for everything in the S3-F15/S12-F5 row. Same latency caveat as the other lifecycle findings.

agent_reports/S12_refute.md § S12R-F2 — Journal.snapshot is a static closure over _HomeScreenState that is never cleared, so a torn-down screen and its five AudioPlayers are retained for the life of the isolate: recorded run proof/01_findings/S12_refute/grep_journal_snapshot_sites.txt: COMMAND: grep -rn 'Journal.snapshot' lib/ lib/ui/home.dart:93: Journal.snapshot = () { EXIT_CODE=0 One assignment in the whole of lib/, no clear. The captured state, verbatim: dart // lib/ui/home.dart:93-103 Journal.snapshot = () { final running = engine.run.values .where((r) => r.status == RunStatus.running) .length; ... return 'timers=${engine.timers.length} actifs=$running ' 'sonnent=$ringing lots=${engine.clones.length} ' '${_foreground ? 'ecran' : 'arriere-plan'}'; }; and the field it is stored in: dart // lib/journal.dart:39-40 /// Optional one-line state snapshot appended to each heartbeat (set by the UI). static String Function()? snapshot;

What to fix

agent_reports/S12_refute.md § S12R-F2 — Journal.snapshot is a static closure over _HomeScreenState that is never cleared, so a torn-down screen and its five AudioPlayers are retained for the life of the isolate: one line in dispose(), before super.dispose(): Journal.snapshot = null;.

How to prove it

agent_reports/S12_refute.md § S12R-F2 — Journal.snapshot is a static closure over _HomeScreenState that is never cleared, so a torn-down screen and its five AudioPlayers are retained for the life of the isolate: a widget test that boots HomeScreen, unmounts it, calls Journal.beatNow(), and asserts the written beat line carries no timers= payload. Red now (the closure still answers), green after.

S13-R4 (S13's refuter) · The permission enumeration is wrong in both directions

What is wrong

The permission enumeration is wrong in both directions

File and line

S13 §3 layer 2 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S13_refute.md §3 · proof/01_findings/S13_refute/apk_manifest_decoded.txt. Decoded with apkanalyzer manifest print rather than a byte scan: eight android.permission.* requests plus one custom dev.sergemio.cadence.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION. DUMP is not a request — it is android:permission="android.permission.DUMP" guarding the androidx.profileinstaller ProfileInstallReceiver. S13 also flattens SCHEDULE_EXACT_ALARM, which ships android:maxSdkVersion="32". This is the artifact a restaurant's IT person reads on the Play listing; it has to match permission for permission.

agent_reports/S13_refute.md § S13-R4 — The permission enumeration is wrong in both directions: proof/01_findings/S13_refute/apk_manifest_decoded.txt, decoded with apkanalyzer manifest print rather than a byte scan; the nine <uses-permission> elements are listed in §2 above, and the receiver guard appears separately as android:permission="android.permission.DUMP" on androidx.profileinstaller.ProfileInstallReceiver.

What to fix

agent_reports/S13_refute.md § S13-R4 — The permission enumeration is wrong in both directions: replace the list with the decoded one; move DUMP into a footnote about the receiver guard; note the maxSdkVersion="32" on SCHEDULE_EXACT_ALARM.

How to prove it

agent_reports/S13_refute.md § S13-R4 — The permission enumeration is wrong in both directions: re-run apkanalyzer manifest print on the release APK and diff.

S13-R5 (S13's refuter) · Two wrong `file:line`s, two wrong line counts, and an internal 13-vs-14 contradiction

What is wrong

Two wrong file:lines, two wrong line counts, and an internal 13-vs-14 contradiction

File and line

S13-F2 "Location" (:20-23, actual :18-21); S13 §7 coverage manifest (AndroidManifest.xml 79 not 74, Info.plist 70 not 78); §1 vs §6 (14 rows vs "the 13 items in §1") at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S13_refute.md §3. Both line counts are correct in research/00_code_map.md:1644,1647. The count should read "13 items on a current install, 14 including the legacy zones key that the v0.4.11 migration removes at store.dart:283".

agent_reports/S13_refute.md § S13-R5 — Two wrong file:lines, two wrong line counts, and an internal 13-vs-14 contradiction: wc -l and awk 'END{print NR}' agree on 79 and 70 (both were run, because a file with no trailing newline makes them differ; neither of these does). grep -n '<application' -A5 returns 18: <application / 19: android:label / 20: android:name / 21: android:icon / 22: <activity.

What to fix

agent_reports/S13_refute.md § S13-R5 — Two wrong file:lines, two wrong line counts, and an internal 13-vs-14 contradiction: correct the citations; state the count as "13 items on a current install, 14 including the legacy zones key that the v0.4.11 migration removes", and mark item 10 conditional as §1 already does in prose.

How to prove it

agent_reports/S13_refute.md § S13-R5 — Two wrong file:lines, two wrong line counts, and an internal 13-vs-14 contradiction: re-run the line counts and reconcile §1 with §6. ---

S14-F10 · `SystemUiMode.immersiveSticky` is set once and never re-asserted, and no kiosk or screen-pinning mechanism exists, so a cook can leave the timer board mid-service

What is wrong

SystemUiMode.immersiveSticky is set once and never re-asserted, and no kiosk or screen-pinning mechanism exists, so a cook can leave the timer board mid-service

File and line

lib/main.dart:33, android/app/src/main/AndroidManifest.xml:22-30 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S14_entry_unowned.md · agent_reports/S14_refute.md §1. Greps re-run and recorded. Not among the five findings S14R-M3 reassigns, but its refuter notes the manifest half belongs to S9/S10.

findings/S14_entry_unowned.md § S14-F10 — SystemUiMode.immersiveSticky is set once and never re-asserted, and no kiosk or screen-pinning mechanism exists, so a cook can leave the timer board mid-service: $ grep -rn "SystemChrome\|SystemUiMode\|setPreferredOrientations" lib/ lib/main.dart:33: SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); $ grep -rn "lockTask\|screenOrientation\|FLAG_KEEP_SCREEN_ON\|setShowWhenLocked" android/ ios/ (no output) The activity block AndroidManifest.xml:22-30 declares launchMode="singleTop", taskAffinity="", configChanges=... and windowSoftInputMode — and no orientation or lock-task attribute.

What to fix

findings/S14_entry_unowned.md § S14-F10 — SystemUiMode.immersiveSticky is set once and never re-asserted, and no kiosk or screen-pinning mechanism exists, so a cook can leave the timer board mid-service: re-assert on resume — in didChangeAppLifecycleState, on AppLifecycleState.resumed, call SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky) again, and await the call in main() so it is applied before the first frame. Screen pinning is a new user-facing capability and is therefore out of scope under R6 — REPORT it: a Settings toggle calling startLockTask() through the existing cadence/volume-style channel pattern, plus a one-page setup note telling the restaurant to enable Screen Pinning in Android settings. S10 owns whether the product wants it.

How to prove it

findings/S14_entry_unowned.md § S14-F10 — SystemUiMode.immersiveSticky is set once and never re-asserted, and no kiosk or screen-pinning mechanism exists, so a cook can leave the timer board mid-service: a widget test asserting that a simulated AppLifecycleState.resumed triggers a SystemChrome.setEnabledSystemUIMode platform message, captured with TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler on SystemChannels.platform. Red at 03a176e (no such message is sent), green after. ---

S2-F20 · `models.dart` points readers at a symbol that does not exist

What is wrong

models.dart points readers at a symbol that does not exist

File and line

lib/engine/models.dart:13 (_kZoneSoundMigration, never declared) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md. grep -rn "_kZoneSoundMigration" lib/ → single hit, the comment itself.

findings/S2_persistence.md § S2-F20 — models.dart points readers at a symbol that does not exist: $ grep -rn "_kZoneSoundMigration" lib/ lib/engine/models.dart:13: // read it (see _kZoneSoundMigration) — nothing else can use it. Single hit; the symbol is never declared.

What to fix

findings/S2_persistence.md § S2-F20 — models.dart points readers at a symbol that does not exist: change the reference to Store.migrateZoneSounds and add the warning the comment is missing: once saveDefs runs, zoneId is gone from storage and the migration is no longer replayable.

How to prove it

findings/S2_persistence.md § S2-F20 — models.dart points readers at a symbol that does not exist: grep -c "_kZoneSoundMigration" lib/ returns 1 today and 0 after. ---

S3-F19 · `assetFor` carries a rewrite no tone can reach

What is wrong

assetFor carries a rewrite no tone can reach

File and line

lib/audio/audio.dart:83-84 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md · agent_reports/S6_refute.md. S3's refuter verified assetFor :83-84. S6's refuter proved an out-of-set tone name does reach it, unvalidated, from the migration and from fromJson. Both are true of different input sets — see CONTRADICTIONS.md §7. Do not delete the rewrite branch until S6R-F1's validation is in place.

findings/S3_audio_alarms.md § S3-F19 — assetFor carries a rewrite no tone can reach: lib/audio/audio.dart:83-84, verbatim: dart static String assetFor(String tone) => '${tone.toLowerCase().replaceAll('buzzer', 'buzz')}.wav'; Proven by test S3: assetFor still carries a Buzzer->Buzz rewrite no tone can reach (proof/01_findings/S3/tests/s3_audio_test.dart): assetFor('Buzz') == 'buzz.wav' with no rewrite involved; assetFor('Buzzer') also yields 'buzz.wav', from an input no caller produces. The existing suite already asserts all twelve tone keys map to files that exist (test/i18n_defaults_test.dart:74-77).

What to fix

findings/S3_audio_alarms.md § S3-F19 — assetFor carries a rewrite no tone can reach: delete the .replaceAll('buzzer', 'buzz'), leaving '${tone.toLowerCase()}.wav'.

How to prove it

findings/S3_audio_alarms.md § S3-F19 — assetFor carries a rewrite no tone can reach: test/i18n_defaults_test.dart:74-77 stays green (it iterates C.tones, which contains Buzz); add expect(SoundBox.assetFor('Buzz'), 'buzz.wav'). Deleting the rewrite cannot change any real call site — grep proves C.tones has no Buzzer entry. ---

S4-F16 + S6-F3 · Dead symbols in the UI layer: two dead colour constants in the palette

What is wrong

Dead symbols in the UI layer: two dead colour constants in the palette

File and line

lib/ui/theme.dart:23 (C.mint) and :29 (C.logoInk); also lib/ui/grid_layout.dart:42, :94 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md · findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. Found twice — S4 handed it to S6, and S6's refuter proved it with an automated sweep over 123 public declarations: one line each, the declaration; raw values 0FA96A/F5F1E8 appear nowhere else, including Kotlin, Swift, XML and test/; no dart:mirrors/noSuchMethod/Function.apply anywhere, so no dynamic lookup is possible.

findings/S4_ui.md § S4-F16 — Dead symbols in the UI layer (handed to S6, not tabulated here): proof/01_findings/S4/greps.txt: ### grep -rn C.mint / C.logoInk outside theme.dart exit=1 (no output: zero consumers) ### grep -rn \.pad lib/ (GridLayout.pad consumers) lib/ui/grid_layout.dart:50: required this.pad, lib/ui/grid_layout.dart:94: pad: math.max(0.0, m.pad), exit=0 (declaration + construction only) findings/S6_dry_deadcode.md § S6-F3 — Two dead colour constants in the palette: proof/01_findings/S6/dead_symbols_grep.txt === 1. C.logoInk — every occurrence in the tracked tree === lib/ui/theme.dart:29: static const logoInk = Color(0xFFF5F1E8); (exit=0) === 2. C.mint — every occurrence, then the same with comment lines removed === lib/ui/theme.dart:23: static const mint = Color(0xFF0FA96A); lib/ui/theme.dart:58:/// mint above 0.35 blending to amber, amber→red over [0.15,0.35], red below. --- comment lines stripped (^\s*//) --- lib/ui/theme.dart:23: static const mint = Color(0xFF0FA96A); (only the declaration survives)

What to fix

findings/S4_ui.md § S4-F16 — Dead symbols in the UI layer (handed to S6, not tabulated here): either delete C.mint, or use it — the urgency-ramp fix in F06 wants a named mint. Point the header wordmark at C.logoInk (lib/ui/header.dart:73) instead of Colors.white; that is what it is for and it changes the rendered colour by a barely-visible amount (#FFFFFF → #F5F1E8, 17.38:1 → 16.0:1, still far above AA). Keep GridLayout.pad only if S6 agrees the test assertions on it are worth the public field; otherwise delete the field and assert the ratio through tileW. findings/S6_dry_deadcode.md § S6-F3 — Two dead colour constants in the palette: delete lib/ui/theme.dart:23 and lib/ui/theme.dart:29.

How to prove it

findings/S4_ui.md § S4-F16 — Dead symbols in the UI layer (handed to S6, not tabulated here): covered by S6's repo-wide dead-code sweep; this stream hands over the four symbols above rather than duplicating that table. --- findings/S6_dry_deadcode.md § S6-F3 — Two dead colour constants in the palette: flutter analyze --fatal-infos --fatal-warnings stays at 0 issues and flutter test stays at 123 passed; git grep -c 'C\.mint\|C\.logoInk' returns 0. ---

S4-F17 · A timer left ringing overnight prints a five-digit minute counter that the tile was never sized for

What is wrong

A timer left ringing overnight prints a five-digit minute counter that the tile was never sized for

File and line

lib/ui/theme.dart:77-82 (fmtTime / fmtUp), consumed at lib/ui/tile.dart:200 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md. S7's refuter independently verified the formatter's behaviour at boundaries while auditing the theme_test.dart spec (fmtTime(-5) is '0:00'). Overlaps S8-F7.

findings/S4_ui.md § S4-F17 — A timer left ringing overnight prints a five-digit minute counter that the tile was never sized for: proof/01_findings/S4/tile_edge_cases.txt: EDGE ringing 1h -> [+88:88, +60:00] EDGE ringing 12h -> [+888:88, +720:00] EDGE ringing 48h -> [+8888:88, +2880:00] (the 8-strings are the LCD ghost, which correctly widens with the value.)

What to fix

findings/S4_ui.md § S4-F17 — A timer left ringing overnight prints a five-digit minute counter that the tile was never sized for: in fmtTime, switch to h:mm:ss above 3600 s; cap the ringing counter at +99:59 and switch to +99:59+ beyond that.

How to prove it

findings/S4_ui.md § S4-F17 — A timer left ringing overnight prints a five-digit minute counter that the tile was never sized for: fmt_time_handles_hours — assert fmtTime(3600) == '1:00:00', fmtTime(10800) == '3:00:00' and that fmtUp never returns a string longer than 7 characters. Red now (60:00, +2880:00), green after. ---

S5-F10 · Two empty catch blocks

What is wrong

Two empty catch blocks

File and line

lib/journal.dart:128, :231 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S5_error_handling.md · agent_reports/S5_refute.md. Verbatim-exact. :231 genuinely is best-effort after the primary copy succeeded at :226. :128 genuinely degrades device to Platform.operatingSystem, and that string is stamped at journal.dart:84, shown at modals.dart:683, used for the export filename at journal.dart:215-223 and the share subject at modals.dart:707 — all four confirmed. The fix's reasoning is wrong in detail (_file is assigned at :73, before device at :75, so Journal.log would be admitted by the :134 guard) but the recommended remedy is fine anyway.

findings/S5_error_handling.md § S5-F10 — Two empty catch blocks: verbatim, lib/journal.dart:114-130: dart static Future<String> _describeDevice() async { try { final info = DeviceInfoPlugin(); ... } catch (_) {} return Platform.operatingSystem; }

What to fix

findings/S5_error_handling.md § S5-F10 — Two empty catch blocks: } catch (e) { debugPrint('[cadence] device probe failed: $e'); } — the journal is not yet writable at this point in init (_file is assigned at :73, device at :75, and the header is buffered at :81-94), so a Journal.log here would be dropped by the guard at :134. A debugPrint plus appending the reason to the returned string is better: return '${Platform.operatingSystem} · description indisponible';.

How to prove it

findings/S5_error_handling.md § S5-F10 — Two empty catch blocks: a test that overrides DeviceInfoPlugin's platform to throw and asserts Journal.device contains description indisponible rather than a bare 'macos'. Red now, green after. ---

S6-F10 · Four styling duplications inside `modals.dart`

What is wrong

Four styling duplications inside modals.dart

File and line

lib/ui/modals.dart:253-258 vs :338-343 (byte-identical, CONFIRMED); :482-492 vs :535-545 (style blocks byte-identical, decorations differ by one number, CONFIRMED); _dashedAdd drawing BorderStyle.solid (CONFIRMED, a name that lies); :54-62 vs :719-727 REFUTED at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. _fieldLabel and _settingLabel share no value at all — size 10.9 vs 14.7, tracking 2.2 vs 2.3, colour muted vs text, gap 8 vs 12. Only the six-line shape matches. S6's proposed merge gives the function four optional parameters that all three settings call sites must override — more code and a worse API than two honest functions. Do not implement (a).

findings/S6_dry_deadcode.md § S6-F10 — Four styling duplications inside modals.dart: proof/01_findings/S6/duplication_sites.txt, D8/D9/D10. (b) verbatim, both copies: dart style: const TextStyle( fontFamily: F.display, fontWeight: FontWeight.w700, fontSize: 20.8, letterSpacing: 1, color: C.text), (a) verbatim, both copies: dart Widget _fieldLabel(String text) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Text(text.toUpperCase(), style: const TextStyle( fontFamily: F.mono, fontSize: 10.9, letterSpacing: 2.2, color: C.muted)), ); dart Widget _settingLabel(String text) => Padding( padding: const EdgeInsets.only(bottom: 12), child: Text(text.toUpperCase(), style: const TextStyle( fontFamily: F.mono, fontSize: 14.7, letterSpacing: 2.3, color: C.text)), );

What to fix

CORRECTED FIX: keep _fieldLabel and _settingLabel separate; their contracts differ and merging them creates optional-parameter bloat. The row is REFUTED to that extent.

How to prove it

findings/S6_dry_deadcode.md § S6-F10 — Four styling duplications inside modals.dart: test/editor_layout_test.dart already renders the editor at 600 px in both languages and asserts the preset row stays on one line; add to it expect(tester.widget<Text>(find.text(I18n('en').call('nameLabel').toUpperCase())).style!.fontSize, 10.9) and the same for the settings label at 14.7. Mutate the shared default from 10.9 to 20: the new assertion goes red. Today no test reads a style in this file. ---

S6-F11 · The tile drop shadow and the corner-radius formula are each written twice in `tile.dart`

What is wrong

The tile drop shadow and the corner-radius formula are each written twice in tile.dart

File and line

lib/ui/tile.dart:441-446 vs :696-701 (shadow); :438-439 vs :598 (radius formula); :218 vs :798 (the literal 20) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. Shadow blocks byte-identical (0x291C211C, blur 4, offset 0,1). math.min(10, 2.6*ch) vs math.min(10.0, 2.6*ch). BorderRadius.circular(20) vs Radius.circular(20 - inset) is a genuine cross-class coupling: the edit-mode outline must track the card's corner.

findings/S6_dry_deadcode.md § S6-F11 — The tile drop shadow and the corner-radius formula are each written twice in tile.dart: proof/01_findings/S6/duplication_sites.txt, D11/D12 dart boxShadow: const [ BoxShadow( color: Color(0x291C211C), blurRadius: 4, offset: Offset(0, 1)) ], dart boxShadow: const [ BoxShadow( color: Color(0x291C211C), blurRadius: 4, offset: Offset(0, 1)) ], dart borderRadius: BorderRadius.circular(math.min(10, 2.6 * ch)), dart final radius = math.min(10.0, 2.6 * ch);

What to fix

findings/S6_dry_deadcode.md § S6-F11 — The tile drop shadow and the corner-radius formula are each written twice in tile.dart: add to lib/ui/theme.dart beside C.tileEdgeW: static const List<BoxShadow> btnShadow = [BoxShadow(color: Color(0x291C211C), blurRadius: 4, offset: Offset(0, 1))]; and static const double tileRadius = 20.0;. Add a top-level double ctlRadius(double ch) => math.min(10.0, 2.6 * ch); to lib/ui/tile.dart. Call sites that change: tile.dart:441-446boxShadow: C.btnShadow; tile.dart:696-701boxShadow: C.btnShadow; tile.dart:439BorderRadius.circular(ctlRadius(ch)); tile.dart:598final radius = ctlRadius(ch);; tile.dart:218BorderRadius.circular(C.tileRadius); tile.dart:798Radius.circular(C.tileRadius - inset).

How to prove it

findings/S6_dry_deadcode.md § S6-F11 — The tile drop shadow and the corner-radius formula are each written twice in tile.dart: git grep -c '0x291C211C' lib/ui/tile.dart returns 0 after the fix (2 today) and git grep -c '2.6 \* ch' lib/ui/tile.dart returns 1 (2 today). Behaviourally, flutter test stays at 123 passed — this file has no test to move, which is itself S7's scope. ---

S6-F12 · The widget-test "open a modal" harness is copied four times, the viewport override twice

What is wrong

The widget-test "open a modal" harness is copied four times, the viewport override twice

File and line

test/announcement_test.dart:200-222 and :189-197; test/editor_layout_test.dart:22-34 and :41-47; test/volume_test.dart:137-158 and :169-183 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. Four MaterialApp → Builder → TextButton(Text('open')) scaffolds; two 5-line viewport blocks differing only in Size.

findings/S6_dry_deadcode.md § S6-F12 — The widget-test "open a modal" harness is copied four times, the viewport override twice: proof/01_findings/S6/duplication_sites.txt, D5/D6 ```

What to fix

Implement the correction stated in findings/S6_dry_deadcode.md · agent_reports/S6_refute.md; do not change the pinned evidence location test/announcement_test.dart:200-222` and `:189-197`; `test/editor_layout_test.dart:22-34` and `:41-47`; `test/volume_test.dart:137-158` and `:169-183 without updating the regression citation.

How to prove it

Add a regression named S6-F12 that reproduces “The widget-test "open a modal" harness is copied four times, the viewport override twice” at 03a176e72ef0075eec86b8915cbe6e93042a3b9d and asserts the opposite after the fix; run it in the whole suite under the mutation-specificity gate in §8.

S6-F13 · The duration-preset label is duplicated across the production/test boundary

What is wrong

The duration-preset label is duplicated across the production/test boundary

File and line

lib/ui/modals.dart:302-304 and test/editor_layout_test.dart:17-20 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. The test's own comment admits the coupling. A test that reimplements the expression cannot detect a change in it. S7's refuter separately mutated the production expression and found both variants go red, so the helper "fails closed" — the duplication is a maintainability defect, not a hole.

findings/S6_dry_deadcode.md § S6-F13 — The duration-preset label is duplicated across the production/test boundary: proof/01_findings/S6/duplication_sites.txt, D7 dart child: Text(p[1] != 0 ? '${p[0]}:${p[1].toString().padLeft(2, '0')}' : '${p[0]}'), dart /// Label as the editor prints it — kept in sync with the chip builder. String presetLabel(List<int> p) => p[1] != 0 ? '${p[0]}:${p[1].toString().padLeft(2, '0')}' : '${p[0]}';

What to fix

findings/S6_dry_deadcode.md § S6-F13 — The duration-preset label is duplicated across the production/test boundary: move the expression into lib/ui/theme.dart beside C.presets as String presetLabel(List<int> p) => p[1] != 0 ? '${p[0]}:${p[1].toString().padLeft(2, '0')}' : '${p[0]}';. Call sites that change: modals.dart:302-304Text(presetLabel(p)); editor_layout_test.dart:17-20 → delete the local copy and import it from package:cadence/ui/theme.dart, which the file already imports at line 12.

How to prove it

findings/S6_dry_deadcode.md § S6-F13 — The duration-preset label is duplicated across the production/test boundary: on the copy, change the shared formatter to emit '${p[0]}m'. After the fix test/editor_layout_test.dart still passes (both sides moved together, correctly) while flutter test shows the row assertion exercising the real string; today, changing only modals.dart:302-304 makes the test fail with "preset 0:30 absent", which is the right failure for the wrong reason — it proves the test is asserting against its own copy. ---

S6-F14 · `ChivoMono-Medium.ttf` is declared, bundled and never selected

What is wrong

ChivoMono-Medium.ttf is declared, bundled and never selected

File and line

pubspec.yaml:61-62; file assets/fonts/ChivoMono-Medium.ttf at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. The refuter ran the experiment S6 only proposed: requested mono weights across the editor (both modes), the settings dialog, the tile in all five status states and the header are exactly {w400, w700}, and removing the Medium face leaves w400 and w700 rasterising to byte-identical pixels. Safe to delete.

findings/S6_dry_deadcode.md § S6-F14 — ChivoMono-Medium.ttf is declared, bundled and never selected: proof/01_findings/S6/font_weight_usage.txt === weights other than w700/w800 requested anywhere in lib/ === lib/ui/home.dart:699: fontWeight: FontWeight.w600, lib/ui/home.dart:694-700 verbatim — no fontFamily, so this is the display family: dart child: Text( msgs.join(' · '), style: const TextStyle( color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w600, ), ), The 14 explicit F.mono sites and their weights, from the same proof file: modals.dart:58 (none → w400), :106 (w700 at :107), :401 (w700), :408 (none → w400), :434 (none → w400), :472 (w700), :488 (w700), :501 (none → w400), :541 (w700), :561 (w700), :639 (w700), :668 (none → w400), :685 (none → w400), :723 (none → w400); tile.dart:456 (w700), :531 (w700), :554 (w700), :707 (w700). Requested set = {w400, w700}.

What to fix

findings/S6_dry_deadcode.md § S6-F14 — ChivoMono-Medium.ttf is declared, bundled and never selected: delete pubspec.yaml:61-62 (the ChivoMono-Medium.ttf asset/weight pair) and the file assets/fonts/ChivoMono-Medium.ttf. Do not touch BigShouldersDisplay-Medium.ttf: display weight 500 is reached, because lib/main.dart:52 sets fontFamily: F.display app-wide and unstyled Text widgets (lib/ui/modals.dart:453, :510, lib/ui/home.dart:708) render at the default w400, which resolves to the nearest declared display weight, 500.

How to prove it

findings/S6_dry_deadcode.md § S6-F14 — ChivoMono-Medium.ttf is declared, bundled and never selected: add a golden test test/font_weights_test.dart that renders Text('88:88', style: TextStyle(fontFamily: F.mono, fontWeight: FontWeight.w500)) and …w400 into two goldens on the copy with ChivoMono-Medium.ttf declared, then removes the declaration and re-renders. If the w400 golden is byte-identical across both runs, no shipped call site can be affected. Cheaper static gate that must also hold: git grep -c 'FontWeight.w500\|FontWeight.w600' lib/ returns 1 and that one hit has no fontFamily within its TextStyle. ---

S6-F17 · The alarm-acknowledgement journal line is written twice in `home.dart`

What is wrong

The alarm-acknowledgement journal line is written twice in home.dart

File and line

lib/ui/home.dart:366-371 and :656-661 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. :366-371 measures against now(), :656-661 against nowMs (the last build's timestamp, up to one 150 ms tick stale). Same headline metric, two clocks — that is a correctness difference, not only a duplication.

findings/S6_dry_deadcode.md § S6-F17 — The alarm-acknowledgement journal line is written twice in home.dart: proof/01_findings/S6/duplication_sites.txt, D15 dart // time-to-acknowledge: how long the alarm rang before someone stopped it final rang = r.rangAt; Journal.log('ARRET ${engine.labelFor(t.id)}', rang == null ? 'alarme coupee' : 'alarme coupee apres ${((now() - rang) / 1000).toStringAsFixed(1)} s'); dart onStop: () { final rang = engine.run[id]?.rangAt; Journal.log('ARRET ${engine.labelFor(t.id)}', rang == null ? 'via la croix' : 'alarme coupee apres ${((nowMs - rang) / 1000).toStringAsFixed(1)} s');

What to fix

findings/S6_dry_deadcode.md § S6-F17 — The alarm-acknowledgement journal line is written twice in home.dart: add one private method to _HomeScreenState: void _logStop(String id, String tileId, {required String nullMsg}) { final rang = engine.run[id]?.rangAt; Journal.log('ARRET ${engine.labelFor(tileId)}', rang == null ? nullMsg : 'alarme coupee apres ${((now() - rang) / 1000).toStringAsFixed(1)} s'); } Call sites that change: two — home.dart:366-371_logStop(id, t.id, nullMsg: 'alarme coupee'); and home.dart:656-661_logStop(id, t.id, nullMsg: 'via la croix');. Both then read the same clock.

How to prove it

findings/S6_dry_deadcode.md § S6-F17 — The alarm-acknowledgement journal line is written twice in home.dart: git grep -c "alarme coupee apres" lib/ui/home.dart returns 1 after the fix (2 today) and git grep -c "nowMs - rang" lib/ returns 0. ---

S6-F18 (pad2 half) · Four independent copies of a two-digit zero-pad

What is wrong

Four independent copies of a two-digit zero-pad

File and line

lib/journal.dart:57 (p), :62 (_day), :222-223; lib/ui/header.dart:145, :147; lib/ui/modals.dart:399, :502, :704; lib/ui/theme.dart:79 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. n.toString().padLeft(2, '0') is a library idiom, not logic. No change to any one of the ten sites would ever have to be made at the others — padding an hour in a filename and padding a minute on a clock face are independent. S6's own fix needs two helpers (pad2 in theme.dart plus Journal._pad2, because journal.dart must not import UI), i.e. it replaces ten copies with two copies plus a layering rule. Do not implement. The title half of S6-F18 is CONFIRMED and lives in the S3-F14 row.

findings/S6_dry_deadcode.md § S6-F18 — Four independent copies of a two-digit zero-pad, and two of the alarm notification title: proof/01_findings/S6/duplication_sites.txt, D14/D16 lib/journal.dart:62: '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; lib/journal.dart:222: '${now.hour.toString().padLeft(2, '0')}h' lib/journal.dart:223: '${now.minute.toString().padLeft(2, '0')}.txt'; lib/ui/header.dart:145: Text(now.hour.toString().padLeft(2, '0'), style: style), lib/ui/header.dart:147: Text(now.minute.toString().padLeft(2, '0'), style: style), lib/ui/modals.dart:303: ? '${p[0]}:${p[1].toString().padLeft(2, '0')}' lib/ui/modals.dart:399: unit == 'min' ? '$value' : value.toString().padLeft(2, '0'), lib/ui/modals.dart:502: _numBox((s.sec % 60).toString().padLeft(2, '0'), lib/ui/modals.dart:704: String p(int n) => n.toString().padLeft(2, '0'); lib/ui/theme.dart:79: return '${v ~/ 60}:${(v % 60).toString().padLeft(2, '0')}'; lib/alarm_backstop.dart:186: title: '⏰ $name', lib/alarm_backstop.dart:259: id: _nid(t.id), title: '⏰ ${t.name}', body: body,

What to fix

CORRECTED FIX: keep the two local pad2 idioms; centralising them increases coupling and does not remove meaningful duplication. No product test is required; the row is REFUTED.

How to prove it

findings/S6_dry_deadcode.md § S6-F18 — Four independent copies of a two-digit zero-pad, and two of the alarm notification title: extend test/backstop_test.dart with a case that spawns a clone, fires showNow for it, and asserts the mocked show call's title argument equals '⏰ Fries [lot 2]'. Today that assertion fails with '⏰ Fries'. ---

S6-F19 + S14-F6 (→S10) + S2-F19 · `README.md` documents the v0.1 architecture and contradicts the shipped app, including on the app's single most important safety property

What is wrong

README.md documents the v0.1 architecture and contradicts the shipped app, including on the app's single most important safety property

File and line

README.md:1-38 (dated "Last update: 2026-07-22"; pinned commit is v0.4.12); README.md:30-34 (the safety-property contradiction); README.md:13-14 (4 keys vs the 9 static const _k declarations in lib/engine/store.dart:15-24 plus lib/journal.dart:23-24) at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · findings/S14_entry_unowned.md · findings/S2_persistence.md · agent_reports/S6_refute.md · agent_reports/S14_refute.md. Found three times. S6's refuter verified all five contradictions verbatim and found a sixth (the key count, which is S2-F19). S14's refuter confirmed it independently by re-running git log and the key/test counts, and reassigned ownership to S10. The final grade takes S6's LOW; S14's MEDIUM rested on the safety-property contradiction at README.md:30-34, which is the sentence Phase 4 must fix first.

findings/S14_entry_unowned.md § S14-F6 — README.md has not been touched since the first commit and now contradicts the code on the app's single most important safety property: $ git log --oneline -1 -- README.md 22902e0 Cadence v0.2.0 — app Flutter (moteur + UI + audio natif) avec lot robustesse $ grep -c "static const _k" lib/engine/store.dart 9 $ grep -c "^\s*test(" test/engine_test.dart 21 README.md:32 verbatim: - **Alarmes en arrière-plan / app tuée** : pas encore branchées (packages \alarm` + `flutter_local_notifications` prévus) — l'usage kiosque (wakelock, app au premier plan) est couvert.findings/S2_persistence.md§ S2-F19 —README.mdclaims 4 SharedPreferences keys;Storewrites 10 in a single boot: recorded runproof/01_findings/S2/04_write_concurrency.txt, after one boot that loads corrupt data, seeds, repairs phrases, migrates zones and sets the volume: ``` W6 — full key inventory actually written by Store, vs the README claim of "4 clés" keys present after one boot: 10 cadence-clones-v1 cadence-clones-v1.corrupt cadence-phrase-repair-v1 cadence-timers-v1 cadence-timers-v1.corrupt cadence-vol cadence-zone-sound-v1 … ```findings/S6_dry_deadcode.md§ S6-F19 —README.mddocuments the v0.1 architecture and contradicts the shipped app:README.md:18,README.md:25andREADME.md:32-34verbatim: ``` -test/engine_test.dart` — 17 tests unitaires (protocole spec §6.1) flutter build apk --release # → build/app/outputs/flutter-apk/app-release.apk

What to fix

findings/S14_entry_unowned.md § S14-F6 — README.md has not been touched since the first commit and now contradicts the code on the app's single most important safety property: rewrite README.md:30-34 against the code: background/killed alarms shipped for Android via lib/alarm_backstop.dart; iOS backstop still absent because InitializationSettings carries no DarwinInitializationSettings (lib/alarm_backstop.dart:73-76); voice scoring present; zones removed in v0.4.11; key count and test count corrected; and delete the stale «Last update: 2026-07-22» line or make it accurate. This is documentation repair, not a feature (R6-compatible). findings/S2_persistence.md § S2-F19 — README.md claims 4 SharedPreferences keys; Store writes 10 in a single boot: amend README.md:14 to (9 clés + siblings « .corrupt », écriture déclenchée immédiatement) and update the store.dart:1-2 header comment to match, combined with the wording fix in S2-F13. findings/S6_dry_deadcode.md § S6-F19 — README.md documents the v0.1 architecture and contradicts the shipped app: rewrite README.md:32-34 to state what is true at 03a176e: the backstop ships (lib/alarm_backstop.dart), voice scoring ships (lib/audio/voice.dart:104), zones are gone (lib/engine/models.dart:6-13); replace the test count with a pointer rather than a number; and change the build command at README.md:25 to the split/bundle form from S6-F1.

How to prove it

findings/S14_entry_unowned.md § S14-F6 — README.md has not been touched since the first commit and now contradicts the code on the app's single most important safety property: the same three commands above, re-run, must agree with the README text. A durable version is a test in the shape of test/source_hygiene_test.dart asserting that README.md does not contain the string pas encore branchées while lib/alarm_backstop.dart exists — red today, green after. --- findings/S2_persistence.md § S2-F19 — README.md claims 4 SharedPreferences keys; Store writes 10 in a single boot: a source-hygiene assertion in the spirit of test/source_hygiene_test.dart: count static const _k declarations in store.dart and assert the README states that number. Red now (4 ≠ 9), green after. --- findings/S6_dry_deadcode.md § S6-F19 — README.md documents the v0.1 architecture and contradicts the shipped app: git grep -c "pas encore branchées\|pas de scoring de voix" README.md returns 0, and git grep -n "zone" README.md returns no line describing zones as a live concept. ---

S6-F22 · `uses-material-design: true` ships an icon font for zero icons

What is wrong

uses-material-design: true ships an icon font for zero icons

File and line

pubspec.yaml:42 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. grep -rn "Icon(\|Icons\.\|IconData\|IconButton" lib/ → zero hits. The only Material widget that renders is Slider (modals.dart:655), which draws no icon.

findings/S6_dry_deadcode.md § S6-F22 — uses-material-design: true ships an icon font for zero icons: proof/01_findings/S6/font_weight_usage.txt and the build log at proof/01_findings/S6/apk_analyze_size_arm64.txt:31 === Icons/Icon( usage in lib === (none) Font asset "MaterialIcons-Regular.otf" was tree-shaken, reducing it from 1645184 to 1256 bytes (99.9% reduction).

What to fix

findings/S6_dry_deadcode.md § S6-F22 — uses-material-design: true ships an icon font for zero icons: set pubspec.yaml:42 to uses-material-design: false.

How to prove it

findings/S6_dry_deadcode.md § S6-F22 — uses-material-design: true ships an icon font for zero icons: flutter test stays at 123 passed and unzip -l <apk> | grep -c MaterialIcons returns 0 (1 today). The behavioural risk is a Material widget requesting an icon the tree-shaker could not see; rule it out by rendering all three surfaces — the board, the editor and the settings dialog — through test/editor_layout_test.dart's existing harness and asserting find.byType(Icon) is findsNothing. Add that assertion first; if it passes, the deletion is safe. ---

S6-F7 · The 7000 ms first voice gap is written three times, in two files that cannot see each other

What is wrong

The 7000 ms first voice gap is written three times, in two files that cannot see each other

File and line

lib/engine/engine.dart:52, lib/engine/models.dart:114, :139 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md · proof/01_findings/S6_refute/mutation_firstvoicegap_3000.txt. The three literals exist. The stated mechanism is false: RunEntry.toJson writes 'voiceGap' unconditionally (models.dart:126), so a restored entry carries its stored value, and _ring overwrites voiceGap = firstVoiceGapMs on every ring (engine.dart:283). Its "How to prove the fix" is also wrong: the refuter mutated 7000→3000 and exactly one test went red — test/engine_test.dart:185 — while test/robustness_test.dart:291, the test S6 names, passed. That line's 7000 is an injected freeze duration, not a voice gap.

findings/S6_dry_deadcode.md § S6-F7 — The 7000 ms first voice gap is written three times, in two files that cannot see each other: proof/01_findings/S6/duplication_sites.txt, D13 lib/engine/engine.dart:52: static const int firstVoiceGapMs = 7000; lib/engine/models.dart:114: this.voiceGap = 7000, lib/engine/models.dart:139: voiceGap: (j['voiceGap'] ?? 7000) as int,

What to fix

findings/S6_dry_deadcode.md § S6-F7 — The 7000 ms first voice gap is written three times, in two files that cannot see each other: move ownership into models.dart, which everything already imports: add const int kDefaultVoiceGapMs = 7000; beside kDefaultSound at models.dart:27; models.dart:114this.voiceGap = kDefaultVoiceGapMs; models.dart:139voiceGap: (j['voiceGap'] ?? kDefaultVoiceGapMs) as int; engine.dart:52static const int firstVoiceGapMs = kDefaultVoiceGapMs; (engine already imports models.dart at line 6). Three call sites, one owner.

How to prove it

findings/S6_dry_deadcode.md § S6-F7 — The 7000 ms first voice gap is written three times, in two files that cannot see each other: on the copy set kDefaultVoiceGapMs = 3000 and run flutter test. test/robustness_test.dart:291 asserts the repeat window inInclusiveRange(6500 + Engine.alarmLeadMs, 7500 + Engine.alarmLeadMs) and goes red. Today, changing only engine.dart:52 leaves models.dart disagreeing and the suite still green on the JSON path, which is the defect. ---

S6-F8 · The running and paused remaining-time computation is written twice in `tile.dart`

What is wrong

The running and paused remaining-time computation is written twice in tile.dart

File and line

lib/ui/tile.dart:185-198 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. Four of six lines identical, including the t.steps! unwrap and the clamp.

findings/S6_dry_deadcode.md § S6-F8 — The running and paused remaining-time computation is written twice in tile.dart: proof/01_findings/S6/duplication_sites.txt, D2 dart case 'running': final stepDur = r!.chain ? t.steps![r.stepIndex].sec : t.durationSec; final rem = (r.endsAt! - widget.nowMs) / 1000.0; pieP = (rem / stepDur).clamp(0.0, 1.0); pieFill = fillFor(pieP); timeText = fmtTime(rem); break; case 'paused': final stepDur = r!.chain ? t.steps![r.stepIndex].sec : t.durationSec; final rem = (r.remainingMs ?? 0) / 1000.0; pieP = (rem / stepDur).clamp(0.0, 1.0); pieFill = C.pausedFill; timeText = fmtTime(rem); break;

What to fix

findings/S6_dry_deadcode.md § S6-F8 — The running and paused remaining-time computation is written twice in tile.dart: add a private helper to _TileViewState: ({double rem, double p}) _remaining(RunEntry r, TimerDef t, double remMs) computing stepDur, rem and p once; the two arms become final m = _remaining(r!, t, (r.endsAt! - widget.nowMs).toDouble()); … and final m = _remaining(r!, t, (r.remainingMs ?? 0).toDouble()); …, each then setting only its own pieFill. Call sites that change: two, both inside lib/ui/tile.dart:185-198.

How to prove it

findings/S6_dry_deadcode.md § S6-F8 — The running and paused remaining-time computation is written twice in tile.dart: lib/ui/tile.dart is at 0.00 % coverage, so this is currently unprovable by test. Extract the arithmetic to a pure top-level double pieFraction(int stepDurSec, double remainingMs) in lib/ui/theme.dart beside fmtTime, and add test/tile_geometry_test.dart asserting pieFraction(60, 30000) == 0.5, pieFraction(60, -1000) == 0.0, pieFraction(60, 999999) == 1.0. Mutate the clamp bounds to (0.0, 2.0): the new test goes red; today nothing does. ---

S6R-F2 · `--obfuscate --split-debug-info` removes 851,968 bytes and was never measured

What is wrong

--obfuscate --split-debug-info removes 851,968 bytes and was never measured

File and line

README.md:25 (the documented build command), android/app/build.gradle.kts:30-36 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S6_refute.md · proof/01_findings/S6_refute/apk_obfuscated_split_debug_info.txt. The Dart AOT snapshot ships with its symbol table intact. The flag strips it into a side file uploaded to Play for crash de-obfuscation. Subtractive, changes no behaviour, needs no product decision — unlike the 1,664,814-byte audio saving S6 reported, which requires re-validating every tone by ear.

agent_reports/S6_refute.md § S6R-F2 — --obfuscate --split-debug-info removes 851,968 bytes and was never measured: proof/01_findings/S6_refute/apk_obfuscated_split_debug_info.txt (EXIT_CODE=0), same tree, same commit, same Flutter: arm64-v8a split APK plain 20,402,081 → obfuscated 19,550,113 (−851,968, −4.2 %) armeabi-v7a split APK plain 17,798,325 → obfuscated 16,749,749 (−1,048,576, −5.9 %) lib/arm64-v8a/libapp.so 5,440,400 → 4,588,432 (−851,968) lib/arm64-v8a/libflutter.so 11,581,856 unchanged

What to fix

agent_reports/S6_refute.md § S6R-F2 — --obfuscate --split-debug-info removes 851,968 bytes and was never measured: document and use flutter build appbundle --release --obfuscate --split-debug-info=build/symbols, and keep build/symbols/ with the release so a crash report can still be read.

How to prove it

agent_reports/S6_refute.md § S6R-F2 — --obfuscate --split-debug-info removes 851,968 bytes and was never measured: unzip -l <aab or apk> | grep libapp.so reports ≈ 4.59 MB where it reports 5.44 MB today, and flutter test stays at 123 passed.

S6R-F3 · `lib/ui/home.dart:124` is a twelfth `RunStatus` decision site and S6's map has no entry for it

What is wrong

lib/ui/home.dart:124 is a twelfth RunStatus decision site and S6's map has no entry for it

File and line

lib/ui/home.dart:124-133 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S6_refute.md. The twelfth site is the good one — an exhaustive switch expression with no default. It is the reason S6-F9's proof protocol is wrong and the working example the fix should be modelled on. Omitting it makes the codebase look uniformly worse than it is.

agent_reports/S6_refute.md § S6R-F3 — lib/ui/home.dart:124 is a twelfth RunStatus decision site and S6's map has no entry for it: independent_verification.txt §7; lib/ui/home.dart:124-133, with the third arm's French string elided per the note at the top of this report: dart final what = switch (r.status) { RunStatus.running => 'en cours, ${((r.endsAt! - n) / 1000).round()} s restantes' '${r.chain ? ' (etape ${r.stepIndex + 1}/${t.steps!.length})' : ''}', RunStatus.paused => 'EN PAUSE, ${((r.remainingMs ?? 0) / 1000).round()} s restantes', RunStatus.ringing => '[…]', };

What to fix

agent_reports/S6_refute.md § S6R-F3 — lib/ui/home.dart:124 is a twelfth RunStatus decision site and S6's map has no entry for it: none to the code. Correct S6-F9's evidence, its proof protocol and D19.

How to prove it

agent_reports/S6_refute.md § S6R-F3 — lib/ui/home.dart:124 is a twelfth RunStatus decision site and S6's map has no entry for it: runstatus_fourth_value_analyze.txt already is the proof.

S6R-F4 · The picker's five-second granularity is a fourth encoding of the 5-second floor

What is wrong

The picker's five-second granularity is a fourth encoding of the 5-second floor

File and line

lib/ui/modals.dart:436-437 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S6_refute.md. The seconds stepper moves in fives, so the smallest non-zero value the picker can express is five seconds. Centralise the floor without these two lines and the picker keeps offering 0:05 under a 10-second floor — the editor displays one number and commits another. Same two lines as M2.

agent_reports/S6_refute.md § S6R-F4 — The picker's five-second granularity is a fourth encoding of the floor: independent_verification.txt §8; lib/ui/modals.dart:436-437 verbatim: dart col('sec', sec, () => bump(() => sec = (sec + 5) % 60), () => bump(() => sec = (sec + 55) % 60)),

What to fix

agent_reports/S6_refute.md § S6R-F4 — The picker's five-second granularity is a fourth encoding of the floor: fold into S6-F4's kMinStepSec: sec = (sec + kMinStepSec) % 60 and (sec + 60 - kMinStepSec) % 60, and add a comment that the picker's granularity and the floor are the same number by design.

How to prove it

agent_reports/S6_refute.md § S6R-F4 — The picker's five-second granularity is a fourth encoding of the floor: set kMinStepSec = 10 on a copy; the picker's seconds must step 0→10→20 and the commit at modals.dart:388 must never raise what the picker displayed. Today the picker steps in fives regardless. ---

S7-F17 · Three tests silently depend on the process working directory

What is wrong

Three tests silently depend on the process working directory

File and line

test/source_hygiene_test.dart:11, test/version_test.dart:16 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md. —

findings/S7_tests.md § S7-F17 — Three tests silently depend on the process working directory: verbatim test/source_hygiene_test.dart:11-14: dart for (final f in Directory('lib') .listSync(recursive: true) .whereType<File>() .where((f) => f.path.endsWith('.dart'))) {

What to fix

findings/S7_tests.md § S7-F17 — Three tests silently depend on the process working directory: resolve from Platform.script, or make the dependency explicit with expect(Directory('lib').existsSync(), isTrue, reason: 'run from the package root'); as the first assertion.

How to prove it

findings/S7_tests.md § S7-F17 — Three tests silently depend on the process working directory: run flutter test test/source_hygiene_test.dart with the working directory set elsewhere; today it throws a FileSystemException, after the fix it fails with the stated reason.

S7-F18 · Global static state is reset inconsistently across test files

What is wrong

Global static state is reset inconsistently across test files

File and line

test/announcement_test.dart, test/editor_layout_test.dart at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S7_tests.md · agent_reports/S7_refute.md §7. The refuter's 13 independent runs found no order dependence today, which bounds the risk without removing it.

findings/S7_tests.md § S7-F18 — Global static state is reset inconsistently across test files: measured per file: test/announcement_test.dart Diag.reset:0 disableForTests:0 test/backstop_test.dart Diag.reset:1 disableForTests:0 test/editor_layout_test.dart Diag.reset:0 disableForTests:0 test/engine_test.dart Diag.reset:0 disableForTests:0 test/i18n_defaults_test.dart Diag.reset:0 disableForTests:0 test/journal_test.dart Diag.reset:1 disableForTests:2 test/robustness_test.dart Diag.reset:1 disableForTests:0 test/store_test.dart Diag.reset:1 disableForTests:0 test/voice_test.dart Diag.reset:1 disableForTests:0 test/volume_test.dart Diag.reset:0 disableForTests:1 Order independence is proven, not assumed: three runs with --test-randomize-ordering-seed random (seeds 1445823227, 2726609422, and the third recorded in random_order_3.txt) all end 00:0N +123: All tests passed!, EXIT_CODE=0.

What to fix

findings/S7_tests.md § S7-F18 — Global static state is reset inconsistently across test files: one shared test/_setup.dart exporting void resetGlobals() calling Diag.reset() and Journal.disableForTests(), invoked from setUp in all 13 files.

How to prove it

findings/S7_tests.md § S7-F18 — Global static state is reset inconsistently across test files: insert a probe that calls Diag.fail('probe', 'x', isCritical: true) as the first test in announcement_test.dart and asserts Diag.critical.value, isEmpty as the first line of the next test. Red today, green after.

S7R-F7 · The `logo_test.dart` specification claims a guard its own assertion cannot deliver

What is wrong

The logo_test.dart specification claims a guard its own assertion cannot deliver

File and line

findings/S7_tests.md §4.6; subject lib/ui/logo.dart:12-16, pubspec.yaml at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S7_refute.md §5, §6. The spec asserts AssetImage.assetName == 'assets/logo/mark_white.png' and says this "catches the asset being dropped from pubspec.yaml". It cannotassetName is simply the string literal handed to Image.asset, identical whether or not the bundle declares the asset. The test is falsifiable but the failure mode it is named for would sail through. Rewrite the spec before implementing.

agent_reports/S7_refute.md § S7R-F7 — The logo_test.dart specification claims a guard its own assertion cannot deliver: subject, verbatim lib/ui/logo.dart:11-17: dart Widget build(BuildContext context) { return Image.asset( 'assets/logo/mark_white.png', height: height, filterQuality: FilterQuality.medium, ); } and pubspec.yaml, verbatim: yaml assets: - assets/audio/ - assets/logo/ Removing the - assets/logo/ line changes nothing about assetName, so the specified assertions stay green.

What to fix

CORRECTED FIX: replace the proposed logo test with a pixel/hash comparison against the approved source asset and a negative control using the stock Flutter logo; the original assertion cannot guard identity.

How to prove it

agent_reports/S7_refute.md § S7R-F7 — The logo_test.dart specification claims a guard its own assertion cannot deliver: delete - assets/logo/ from pubspec.yaml on a copy; the amended test goes red, the specified one stays green. ---

S8-R3 (S8's refuter) · The migration's own comment states an invariant the migration violates

What is wrong

The migration's own comment states an invariant the migration violates

File and line

lib/engine/store.dart:213-214 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S8_refute.md §2. The doc comment on repairGeneratedPhrases claims it "changes NOTHING about what the app says today (the same words are regenerated)". Companion to S2-F10, which its own refuter confirmed: the migration does delete the operator's own sentence if it re-runs. Two independent refuters now contradict the same comment.

agent_reports/S8_refute.md § S8-R3 — The migration's own comment states an invariant the migration violates: proof/01_findings/S8_refute/probe_s8_refute.txt, R-PROBE-4: COUNT_OF_SEEDED_TIMERS_WHOSE_ENGLISH_ANNOUNCEMENT_CHANGED=3, STORE_COMMENT_CLAIM_AT_store.dart:213_IS=FALSE.

What to fix

agent_reports/S8_refute.md § S8-R3 — The migration's own comment states an invariant the migration violates: delete the false clause and state the truth — the migration regenerates the phrase from readyPhrase, which differs from the pre-v0.4.7 wording for plural dish names. AGENT_RULES §"Code Changes" preserves comments recording prior experiment outcomes; this one records a wrong one.

How to prove it

agent_reports/S8_refute.md § S8-R3 — The migration's own comment states an invariant the migration violates: the R-PROBE-4 table, promoted into store_test.dart as an assertion that the migration's output equals the pre-migration announcement for all seven, which is red today for three.

S8-R5 (S8's refuter) · Three wrong `file:line` citations and five wrong line counts, all contradicting the shared code map

What is wrong

Three wrong file:line citations and five wrong line counts, all contradicting the shared code map

File and line

S8-F5 "Location"; S8 §8 coverage manifest at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S8_refute.md §2. AndroidManifest.xml is 79 lines (S8 says 74), ios/Runner/Info.plist 70 (S8 says 78), web/index.html 46 (38), web/manifest.json 35 (33), pubspec.yaml 68 (69). Every one of these is correct in research/00_code_map.md:1641,1644,1647,1649,1650, the shared map every stream was told to use rather than re-derive. Every Dart line count in S8's manifest is right; only the non-Dart ones are wrong, which is consistent with their not having been measured. Note S13-R5 and S3's manifest carry the same class of slip on the same two files.

agent_reports/S8_refute.md § S8-R5 — Three wrong file:line citations and five wrong line counts, all contradicting the shared code map: wc -l and awk 'END{print NR}' agree on every count above (both were run because a file with no trailing newline makes them differ; none of these do). grep -n 'android:label' android/app/src/main/AndroidManifest.xml returns 19: android:label="Cadence" and nothing else.

What to fix

agent_reports/S8_refute.md § S8-R5 — Three wrong file:line citations and five wrong line counts, all contradicting the shared code map: correct the citations. Every Dart line count in S8's manifest is right; only the non-Dart ones are wrong, which is consistent with their not having been measured.

How to prove it

agent_reports/S8_refute.md § S8-R5 — Three wrong file:line citations and five wrong line counts, all contradicting the shared code map: re-run wc -l over the manifest's file column and diff against the table.

S8-R6 (S8's refuter) · "32 hardcoded user-visible strings" counts glyphs, operator text and unreachable fallbacks

What is wrong

"32 hardcoded user-visible strings" counts glyphs, operator text and unreachable fallbacks

File and line

S8 §3 and the summary table at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S8_refute.md §2. The refuter opened all 39 cited sites: every literal S8 lists is present at the line it gives, and no row is fabricated. But the headline count conflates three categories, and only ~15 are translation defects. Publish 15, not 32.

agent_reports/S8_refute.md § S8-R6 — "32 hardcoded user-visible strings" counts glyphs, operator text and unreachable fallbacks: every site printed with its source line, verified individually. Spot-checking the one I most suspected of being unreachable — the default chain steps — shows it is reachable in one tap (lib/ui/modals.dart:271-280): dart onTap: () => setState(() { mode = 'chain'; if (steps.isEmpty) { steps = [ StepDef(name: 'Sear', sec: 120), StepDef(name: 'Rest', sec: 60), ]; } }), Tapping Multi-étapes on a new timer puts two English cooking verbs into a French editor immediately. That row is real and, if anything, under-stated.

What to fix

agent_reports/S8_refute.md § S8-R6 — "32 hardcoded user-visible strings" counts glyphs, operator text and unreachable fallbacks: split the table into the three classes above and headline the middle number.

How to prove it

agent_reports/S8_refute.md § S8-R6 — "32 hardcoded user-visible strings" counts glyphs, operator text and unreachable fallbacks: the classification is in this finding; it needs no test, only the edit. ---

S9-F13 · Stale `TODO` above a correct application id invites an irreversible change

What is wrong

Stale TODO above a correct application id invites an irreversible change

File and line

android/app/build.gradle.kts:20-21 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S9_platform_config.md · agent_reports/S9_refute.md. An application id cannot be changed after first publication. Delete the TODO.

findings/S9_platform_config.md § S9-F13 — Stale TODO above a correct application id invites an irreversible change: Verbatim, android/app/build.gradle.kts:19-21: kotlin defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "dev.sergemio.cadence" Consistency across all four declaration sites: | Site | Value | |---|---| | android/app/build.gradle.kts:8 (namespace) | dev.sergemio.cadence | | android/app/build.gradle.kts:21 (applicationId) | dev.sergemio.cadence | | ios/Runner.xcodeproj/project.pbxproj:385, :564, :586 | dev.sergemio.cadence | | Kotlin source path | android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt | Why the TODO is dangerous advice (https://developer.android.com/studio/build/application-id, retrieved 2026-08-04, captures/android_application_id.txt:74 and :102), verbatim: > Important: Once you publish your app, you should never change the application ID. If you > change the application ID, Google Play Store treats the upload as a completely different app. If > you want to upload a new version of your app, you must use the same application ID and signing > certificate as when originally published. > > Don't change the application ID after you publish your app. If you change it, Google Play Store > treats the subsequent upload as a new app.

What to fix

findings/S9_platform_config.md § S9-F13 — Stale TODO above a correct application id invites an irreversible change: Delete line 20. Optionally replace it with a one-line note recording that the id is final and must not change post-publication.

How to prove it

findings/S9_platform_config.md § S9-F13 — Stale TODO above a correct application id invites an irreversible change: grep -c "Specify your own unique Application ID" android/app/build.gradle.kts returns 1 now and 0 after, while grep -c 'applicationId = "dev.sergemio.cadence"' stays 1. ---

S9-F14 + S14-F11 (→S7) · `ios/RunnerTests/RunnerTests.swift` is a compiled test target whose single test asserts nothing, wired into the shared scheme's test action

What is wrong

ios/RunnerTests/RunnerTests.swift is a compiled test target whose single test asserts nothing, wired into the shared scheme's test action

File and line

ios/RunnerTests/RunnerTests.swift:7-10; 19 RunnerTests references in ios/Runner.xcodeproj/project.pbxproj at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S9_platform_config.md · findings/S14_entry_unowned.md · agent_reports/S9_refute.md · agent_reports/S14_refute.md. Found twice. S14's refuter confirmed the file is STOCK-TEMPLATE and counted the 19 project references; ownership reassigned to S7 per S14R-M3.

findings/S14_entry_unowned.md § S14-F11 — ios/RunnerTests/RunnerTests.swift is a compiled test target whose single test asserts nothing: the entire file, verbatim: swift import Flutter import UIKit import XCTest class RunnerTests: XCTestCase { func testExample() { // If you add code to the Runner application, evaluate adding tests here. // See https://developer.apple.com/documentation/xctest for more information about using XCTest. } } Marked STOCK-TEMPLATE in proof/01_findings/S14/stock_template_comparison.txt; grep -c "RunnerTests" ios/Runner.xcodeproj/project.pbxproj19. findings/S9_platform_config.md § S9-F14 — ios/RunnerTests/RunnerTests.swift is an empty stub wired into the shared scheme's test action: Verbatim, ios/RunnerTests/RunnerTests.swift in full (12 lines): swift import Flutter import UIKit import XCTest class RunnerTests: XCTestCase { func testExample() { // If you add code to the Runner application, evaluate adding tests here. // See https://developer.apple.com/documentation/xctest for more information about using XCTest. } } It is wired into the shared scheme, ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme:43-71: 43: <TestAction 44: buildConfiguration = "Debug" 65: BuildableName = "RunnerTests.xctest" Static analysis only — no Xcode on this machine, so the test has not been executed.

What to fix

findings/S14_entry_unowned.md § S14-F11 — ios/RunnerTests/RunnerTests.swift is a compiled test target whose single test asserts nothing: either delete testExample() and replace it with one real test — construct the AppDelegate, invoke getAlarmVolume on cadence/volume and assert it returns nil as ios/Runner/AppDelegate.swift:54-57 specifies, which is the Dart-side contract lib/ui/home.dart:226 depends on — or delete the target entirely and record the decision. It cannot be run or verified here: this machine has Command Line Tools only, no Xcode and no CocoaPods (baseline). Note that as a static-analysis-only stream, S14 states this as a defect in the file, not as a claim about iOS behaviour. findings/S9_platform_config.md § S9-F14 — ios/RunnerTests/RunnerTests.swift is an empty stub wired into the shared scheme's test action: Either delete the RunnerTests target and its scheme entry, or give it a real assertion — the natural first one being a channel test that getAlarmVolume on cadence/volume returns nil on iOS, which is the documented, deliberate platform difference at ios/Runner/AppDelegate.swift:54-57.

How to prove it

findings/S14_entry_unowned.md § S14-F11 — ios/RunnerTests/RunnerTests.swift is a compiled test target whose single test asserts nothing: xcodebuild test -workspace ios/Runner.xcworkspace -scheme Runner on a Mac with Xcode: the new test must fail if AppDelegate.swift:54-57 is changed to return a number instead of nil. Cannot be executed in this environment; the missing artifact is Xcode. --- findings/S9_platform_config.md § S9-F14 — ios/RunnerTests/RunnerTests.swift is an empty stub wired into the shared scheme's test action: Once Xcode exists, mutate AppDelegate.swift so getAlarmVolume returns 0.5 instead of nil on a copy under a scratch working copy (R10), run xcodebuild test, and confirm the failing set is exactly the named test with result failure, not a load-time error; revert and confirm git status --porcelain on the copy is empty (R8). ---

S9-F15 · `org.gradle.jvmargs` demands 12 GB of JVM memory

What is wrong

org.gradle.jvmargs demands 12 GB of JVM memory

File and line

android/gradle.properties:1 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S9_platform_config.md · agent_reports/S9_refute.md. —

findings/S9_platform_config.md § S9-F15 — org.gradle.jvmargs demands 12 GB of JVM memory: Verbatim, android/gradle.properties in full (6 lines): properties org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true # This newDsl flag was added by the Flutter template android.newDsl=false # This builtInKotlin flag was added by the Flutter template android.builtInKotlin=false For reference, the release APK build on this machine took 452.3s in the Gradle phase (proof/00_toolchain/SUMMARY.md), so the setting is not buying a fast build.

What to fix

findings/S9_platform_config.md § S9-F15 — org.gradle.jvmargs demands 12 GB of JVM memory: Reduce to -Xmx4G -XX:MaxMetaspaceSize=1G, which comfortably builds this project, and re-raise only if a measured OOM justifies it.

How to prove it

findings/S9_platform_config.md § S9-F15 — org.gradle.jvmargs demands 12 GB of JVM memory: flutter build appbundle --release exits 0 with the reduced setting; the recorded wall time in proof/ does not regress by more than 10%. ---

S9-F16 · The two OEM quick-boot actions on the boot receiver cannot be delivered to a non-exported receiver

What is wrong

The two OEM quick-boot actions on the boot receiver cannot be delivered to a non-exported receiver

File and line

android/app/src/main/AndroidManifest.xml:48-56 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S9_platform_config.md · agent_reports/S9_refute.md §Part 5. The facts hold; the reason S9 gives for them and the fix it proposes are both wrong. Do not implement S9-F16's fix as written — re-derive it from the refuter's Part 5 against https://developer.android.com/guide/topics/manifest/receiver-element.

findings/S9_platform_config.md § S9-F16 — The two OEM quick-boot actions on the boot receiver cannot be delivered to a non-exported receiver: Verbatim, android/app/src/main/AndroidManifest.xml:48-56: xml <receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"> <intent-filter> <action android:name="android.intent.action.BOOT_COMPLETED"/> <action android:name="android.intent.action.MY_PACKAGE_REPLACED"/> <action android:name="android.intent.action.QUICKBOOT_POWERON" /> <action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/> </intent-filter> </receiver> Why BOOT_COMPLETED is unaffected — <receiver> element reference (https://developer.android.com/guide/topics/manifest/receiver-element, retrieved 2026-08-04, captures/android_receiver_element.txt:116), verbatim: > android:exported — Whether the broadcast receiver can receive messages from non-system sources > outside its application. It's "true" if it can, and "false" if not. If "false", the only messages > the broadcast receiver receives are those sent by the system, components of the same application, > or applications with the same user ID. Why the two OEM actions are different — Broadcasts overview (https://developer.android.com/develop/background-work/background-tasks/broadcasts, retrieved 2026-08-04, captures/android_broadcasts_overview.txt:170), verbatim: > Some system broadcasts come from highly privileged apps, such as Bluetooth and telephony, that are > part of the Android framework but don't run under the system's unique process ID (UID). To receive > all system broadcasts, including broadcasts from highly privileged apps, flag your receiver with > RECEIVER_EXPORTED.

What to fix

CORRECTED FIX: do not export the receiver on the refuted theory. Re-derive the desired OEM behaviour against agent_reports/S9_refute.md Part 5 and Android's receiver rules; add a manifest-merger test before any change.

How to prove it

findings/S9_platform_config.md § S9-F16 — The two OEM quick-boot actions on the boot receiver cannot be delivered to a non-exported receiver: grep -c QUICKBOOT_POWERON android/app/src/main/AndroidManifest.xml returns 2 now and 0 after; flutter test and flutter analyze stay green, and the merged release manifest still contains the BOOT_COMPLETED and MY_PACKAGE_REPLACED actions. ---

S9R-M01 + S14-F9 (→S9) · The iOS launch image is the untouched Flutter template on both platforms, and S9's own manifest records the opposite

What is wrong

The iOS launch image is the untouched Flutter template on both platforms, and S9's own manifest records the opposite

File and line

ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png, LaunchImage@2x.png, LaunchImage@3x.png; android/app/src/main/res/drawable/launch_background.xml:4 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S9_refute.md §Part 6 · findings/S14_entry_unowned.md · agent_reports/S14_refute.md. Found twice, and one of the two sightings corrects a false negative in the audit itself. S9's coverage manifest claimed the three iOS launch images "all three differ, so the launch image was customised". Both statements are false — S9 compared against the wrong template location; measured against flutter_template_images-5.0.0 all three are byte-identical, and all three are 1×1 pixel images. S14's refuter independently found all six launch files STOCK-TEMPLATE and noted the three iOS PNGs share one SHA-256 at 68 bytes each.

findings/S14_entry_unowned.md § S14-F9 — The launch screen is the stock template on both platforms and does not match the app's first frame, so every cold start flashes white or black before the beige board: proof/01_findings/S14/stock_template_comparison.txt marks android/app/src/main/res/values/styles.xml, values-night/styles.xml, drawable/launch_background.xml, drawable-v21/launch_background.xml, ios/Runner/Base.lproj/LaunchScreen.storyboard, ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and its Contents.json and README.md all STOCK-TEMPLATE. Verbatim, drawable/launch_background.xml:4: xml <item android:drawable="@android:color/white" /> and drawable-v21/launch_background.xml:4: xml <item android:drawable="?android:colorBackground" /> The three iOS launch images are byte-identical to each other and 68 bytes, 1×1 pixels (measured from the PNG IHDR header). agent_reports/S9_refute.md § S9R-M01 — The iOS launch image is also the untouched Flutter template, and S9's manifest records the opposite: $ for f in ios/Runner/Assets.xcassets/LaunchImage.imageset/*.png; do … md5 vs ~/.pub-cache/hosted/pub.dev/flutter_template_images-5.0.0/templates/app/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/$(basename $f) … IDENTICAL LaunchImage.png IDENTICAL LaunchImage@2x.png IDENTICAL LaunchImage@3x.png $ sips -g pixelWidth -g pixelHeight -g hasAlpha ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png pixelWidth: 1 pixelHeight: 1 hasAlpha: yes And the asset catalogue has one commit in its whole history: $ git log --oneline -- ios/Runner/Assets.xcassets/ 22902e0 Cadence v0.2.0 — app Flutter (moteur + UI + audio natif) avec lot robustesse

What to fix

findings/S14_entry_unowned.md § S14-F9 — The launch screen is the stock template on both platforms and does not match the app's first frame, so every cold start flashes white or black before the beige board: define <color name="launch_background">#F4EFE4</color> in values/colors.xml (the file already exists and already holds the same hex under a different name), point both launch_background.xml variants at it instead of @android:color/white / ?android:colorBackground, and set both LaunchTheme and NormalTheme windowBackground to it in values/ and values-night/ so dark mode does not diverge. On iOS set the storyboard view background to the same colour. No new user feature. agent_reports/S9_refute.md § S9R-M01 — The iOS launch image is also the untouched Flutter template, and S9's manifest records the opposite: Replace the three LaunchImage PNGs with the Cadence mark at 1×/2×/3×, or point LaunchScreen.storyboard at a solid #F4EFE4 background and drop the image view. Compliance plumbing, no new capability.

How to prove it

findings/S14_entry_unowned.md § S14-F9 — The launch screen is the stock template on both platforms and does not match the app's first frame, so every cold start flashes white or black before the beige board: flutter build apk --release then a scripted cold-start screenshot of the first 200 ms compared against #F4EFE4; failing a device, an assertion on the resolved resource: aapt2 dump resources shows windowBackground resolving to the launch colour rather than @android:color/white. At 03a176e it resolves to white. --- agent_reports/S9_refute.md § S9R-M01 — The iOS launch image is also the untouched Flutter template, and S9's manifest records the opposite: md5 -q ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png must not equal the flutter_template_images-5.0.0 digest, and sips -g pixelWidth must report more than 1. Red now (identical, 1 px), green after.

S9R-M02 · The two maskable web icons are still the Flutter logo, while the other three web icons were replaced

What is wrong

The two maskable web icons are still the Flutter logo, while the other three web icons were replaced

File and line

web/icons/Icon-maskable-192.png and web/icons/Icon-maskable-512.png; consumers at web/manifest.json:22-33 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

agent_reports/S9_refute.md §Part 6 · agent_reports/S11_refute.md §6.1. Both refuters agree the two maskable icons are byte-identical Flutter template. They disagree about the other three — see CONTRADICTIONS.md §4. The finding closes either way if web/ is deleted (S6-F21 merged row).

agent_reports/S9_refute.md § S9R-M02 — The two maskable web icons are still the Flutter logo, while the other three web icons were replaced: $ for f in web/icons/Icon-192.png web/icons/Icon-512.png web/icons/Icon-maskable-192.png \ web/icons/Icon-maskable-512.png web/favicon.png; do compare md5 against ~/.pub-cache/hosted/pub.dev/flutter_template_images-5.0.0/templates/app/$f NO-TEMPLATE web/icons/Icon-192.png <- replaced; no template counterpart ships NO-TEMPLATE web/icons/Icon-512.png <- replaced IDENTICAL web/icons/Icon-maskable-192.png <- untouched Flutter template IDENTICAL web/icons/Icon-maskable-512.png <- untouched Flutter template NO-TEMPLATE web/favicon.png <- replaced And the manifest entries that consume them, verbatim from web/manifest.json:22-33: json { "src": "icons/Icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "icons/Icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }

What to fix

agent_reports/S9_refute.md § S9R-M02 — The two maskable web icons are still the Flutter logo, while the other three web icons were replaced: Regenerate both maskable icons from assets/icon/ic_legacy.png with the safe-zone padding the maskable spec requires, in the same pass that fixes S9-F12. If web/ is deleted instead, this closes with it.

How to prove it

agent_reports/S9_refute.md § S9R-M02 — The two maskable web icons are still the Flutter logo, while the other three web icons were replaced: A shell assertion in CI: neither maskable icon's md5 can equal its flutter_template_images-5.0.0 counterpart. Red now (both equal), green after.

S2-C1 · Concurrency: checked, no defect found

What is wrong

Concurrency: checked, no defect found

File and line

lib/engine/store.dart:133-154, lib/journal.dart:163-180 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S2_persistence.md · agent_reports/S2_refute.md. Probes W2/W3 reproduce; the single-isolate reasoning and the disjoint-key argument hold for the three data keys. Scope limit: it does not cover the clones→run coupling in S2R-M1, which is a load-ordering issue, not a concurrency one.

Replay the named proof at findings/S2_persistence.md · agent_reports/S2_refute.md against lib/engine/store.dart:133-154`, `lib/journal.dart:163-180 on a disposable copy of 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

What to fix

No product change. Preserve this checked-clear result and its proof path.

How to prove it

No red/green fix test. Re-run the named negative-space probe only if the covered code changes.

S3-F18 · No defect: the past-deadline guard, the debounce, the exact-to-inexact degradation, the reboot receiver and the no-double-ring behaviour are all correct

What is wrong

No defect: the past-deadline guard, the debounce, the exact-to-inexact degradation, the reboot receiver and the no-double-ring behaviour are all correct

File and line

lib/alarm_backstop.dart:181, :143-148, :208-220 at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S3_audio_alarms.md · agent_reports/S3_refute.md. The refuter mutated five of these mechanisms (_nid, Importance.max, the onForeground cancel loop, the iOS settings, the chain sum) and all five were detected. It also probed the onForeground/sync interleave for a cancel-after-rearm race and found it unreachable. Note the tension with S7-F4, which proves the past-deadline guard is not what makes its own test pass — the mechanism is correct, its test is not.

findings/S3_audio_alarms.md § S3-F18 — no defect: the past-deadline guard, the debounce, the exact-to-inexact degradation, the reboot receiver and the no-double-ring behaviour are all correct: proof/01_findings/S3/02_test_full_suite_with_s3.txt — 163 tests, 0 failures. Mutation proof/01_findings/S3/mutations/M2_backstop_chain.patch neuters the chain sum (at += t.steps![i].sec * 1000;at += 0;) and the chain test goes red: M2_backstop_chain.after.txt, EXIT_CODE=1.

What to fix

No product change. Preserve this checked-clear result and its proof path.

How to prove it

No red/green fix test. Re-run the named negative-space probe only if the covered code changes.

S4-F18 · What was checked and found sound

What is wrong

What was checked and found sound

File and line

— at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S4_ui.md. —

Replay the named proof at findings/S4_ui.md against on a disposable copy of 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

What to fix

No product change. Preserve this checked-clear result and its proof path.

How to prove it

No red/green fix test. Re-run the named negative-space probe only if the covered code changes.

S6-F23 · REPORTED, not proposed: the 16 WAVs ship uncompressed

What is wrong

REPORTED, not proposed: the 16 WAVs ship uncompressed

File and line

assets/audio/*.wav (15 files), android/app/src/main/res/raw/cadence_alarm.wav at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Evidence

findings/S6_dry_deadcode.md · agent_reports/S6_refute.md. Correctly framed under R6 as a product decision, not a defect. The 1,664,814-byte saving requires re-validating every tone by ear; S6R-F2 offers 851,968 bytes with no product decision at all.

findings/S6_dry_deadcode.md § S6-F23 — REPORTED, not proposed: the 16 WAVs ship uncompressed: proof/01_findings/S6/wav_vs_compressed_audio.txt FILE WAV MP3_96k OPUS_64k beep.wav 30914 5060 4912 bell.wav 97064 14151 14914 bowl.wav 308744 42676 46073 buzz.wav 61784 9135 11356 cascade.wav 366956 50827 59436 chime.wav 82952 11956 14166 chirp.wav 75182 11016 9510 click-down.wav 28268 4747 2405 click-up.wav 28268 4747 2079 coin.wav 206870 28884 31123 fanfare.wav 192316 27003 33729 marimba.wav 87356 12583 15200 ping.wav 52964 7881 8782 pop.wav 124400 17599 16245 step.wav 54728 8195 3782 cadence_alarm.wav 142928 20420 26577 -------------------------------------------------------------- TOTAL (16 files) 1941694 276880 300289 bytes saved vs WAV: mp3@96k = 1664814 opus@64k = 1641405 1,664,814 bytes is 3.1 % of today's universal APK and 8.1 % of the 20,623,249-byte arm64 APK.

What to fix

No product change. Preserve this checked-clear result and its proof path.

How to prove it

No red/green fix test. Re-run the named negative-space probe only if the covered code changes.