Stream S1: finding and refutation

S1 — Timer engine correctnessfindings/S1_engine.md · raw .md

S1 — Timer engine correctness

Scope: lib/engine/engine.dart (432 lines) and lib/engine/models.dart (160 lines), at pinned commit 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

Method: the engine is pure Dart, so every claim below is backed by an executed test. A byte-identical copy of the repo was made at a scratch working copy (git archive HEAD; engine.dart sha256 9bfd65ed… identical to the subject) and all probes were run there. The subject repo was never written to — verified after the run: git status --porcelain empty, HEAD still 03a176e.

Proof artefacts, all recorded through proof/run_and_record.sh [not published], in proof/01_findings/S1/:

Artefact Content
s1_facts_test.dart + facts_run.txt 33 ground-truth probes, all PASS — what the engine does today
s1_restart_test.dart + restart_run.txt 10 restart/restore probes, all PASS
s1_defects_test.dart + defects_run.txt 15 tests asserting the CORRECT behaviour — 14 FAIL (+1 -14); the one that passes is S1-F10's golden-instant test, whose gap is proven by mutation instead
baseline_suite.txt the untouched 123-test suite on the copy: +123: All tests passed!
mutations/MUT-*.patch + .txt six mutations of engine.dart, each with the full suite result

Findings: 1 BLOCKER, 2 HIGH, 7 MEDIUM, 2 LOW.


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

  • Severity: BLOCKER
  • Location: lib/engine/engine.dart:312 (valid at 03a176e); deadline set at :177 and :182, clock source EngineHost.now() at :11
  • What is wrong: every deadline is an absolute epoch-millisecond value (endsAt = now + duration) and tick() fires purely on n >= r.endsAt! - alarmLeadMs. The engine has no notion of a monotonic clock and no jump detection anywhere. If the tablet's wall clock moves backwards — an operator correcting the date, a first NTP sync after the device was off or its clock had drifted, a dead RTC cell — every running timer's deadline moves that far into the future with it. The alarm does not fire late; it does not fire at all until real time has crossed the old, now-wrong epoch value. Nothing in the engine detects it, nothing reports it, and the tile keeps counting down on screen from the same wrong arithmetic.
  • Evidence: 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:
// 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. - Why it matters for a restaurant kitchen: this is the one failure mode the product cannot survive. A timer that rings late is a burnt dish; a timer that never rings, on a board the cook has stopped watching precisely because the board is supposed to watch for him, is a fire. The trigger is ordinary: a kitchen tablet lives in a hot, greasy room, gets powered down between services, and the first thing a manager does with a tablet showing the wrong time is set the time. - Proposed fix: add int monotonicMs() to EngineHost (a process-lifetime Stopwatch, which is not derived from the wall clock) and have tick() compare consecutive deltas. When (now - lastNow) - (mono - lastMono) exceeds a threshold (2000 ms is well clear of the 150 ms beat and of ordinary NTP slew), the wall clock has been stepped: shift endsAt, armedAt, rangAt and nextVoiceAt of every run entry by exactly that delta, persist once, and route a line into the journal. This is defect repair inside the existing state machine, not a new user-facing capability. - How to prove the fix: 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.


S1-F2 — A throwing host callback makes tick() delete the timer it has just set to ringing

  • Severity: HIGH
  • Location: lib/engine/engine.dart:338-343, reached from :286 and :328
  • What is wrong: tick() wraps each run entry in a try/catch (_) whose stated purpose is to drop a structurally corrupt entry so it cannot freeze the board. But the try also encloses the two host callbacks, host.onAlarmFire(t) at :286 and host.onStepAdvance(...) at :328. A throw from either one is indistinguishable from corrupt data, so the handler deletes a perfectly healthy run entry — one that _fireAlarm has already moved to RunStatus.ringing and persisted. The dish stops ringing, its tile disappears from the board, and it never rings again. The same handler applied to a chain deletes the entire remaining cook.
  • Evidence: the code path, verbatim:
// 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):

// 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. - Why it matters for a restaurant kitchen: a dish that vanishes from the board is worse than a dish that rings late, because the cook has no signal that anything happened. The engine records the drop nowhere the operator can see it. - Proposed fix: 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. - How to prove the fix: 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.


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

  • Severity: HIGH
  • Location: lib/engine/engine.dart:342
  • What is wrong: the catch (_) handler calls host.persistRun() at :342. host.persistRun() is also called from inside the try, at :285 (in _fireAlarm) and :327 (chain advance). If persistRun is the thing that threw, the handler re-invokes it and the second throw is unguarded: it escapes tick() entirely, past the remaining entries of the viewList() loop. The method's own comment two lines above — "one bad entry must never silence the timers after it in the loop" — is the exact invariant its recovery path breaks. The offending entry has already been removed at :341 before the throw, so the board bleeds one dish per beat while every later dish is skipped.
  • Evidence: proof/01_findings/S1/facts_run.txt, probe B2 (PASS = defect confirmed):
// 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. - Why it matters for a restaurant kitchen: the failure is silent and progressive. Dishes disappear from the board one at a time, six per second, with no alarm and no banner. - Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/engine/engine.dart:370-374
  • What is wrong: when a caller passes a steps list of length 1, saveDef degrades it to a single timer by discarding the list — and with it the only duration the caller supplied. The step's sec is never transferred to durationSec. If the caller also passed no durationSec (or one below the 5 s floor), the timer becomes a five-second timer; if it passed an unrelated durationSec from another field, that unrelated value wins.
// engine.dart:366-374
    if (steps != null) {
      for (final s in steps) {
        if (s.sec < 5) s.sec = 5;
      }
      if (steps.length < 2) steps = null; // degenerate chain → single
    }
    if (steps == null && (durationSec == null || durationSec < 5)) {
      durationSec = 5;
    }
  • Evidence: proof/01_findings/S1/facts_run.txt, probes I1 and I2 (PASS = defect confirmed):
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. - Why it matters for a restaurant kitchen: a ninety-minute confit silently becomes a five-second timer that rings the instant it is started. The cook sees a timer he did not ask for and has no way to know the real duration was discarded rather than mistyped. - Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/engine/engine.dart:156-159; contract stated at lib/engine/models.dart:147-149
  • What is wrong: _nextBatchNo folds over the live clones only. Because stopTimer deletes a clone from clones (:219-223), the highest-ever number is forgotten as soon as the batches carrying it stop. A dish whose batches have all been stopped starts again at lot 2.
// engine.dart:156-159
  int _nextBatchNo(String pid) => clones
          .where((c) => c.parentId == pid)
          .fold(1, (m, c) => math.max(m, c.batchNo)) +
      1;

models.dart:147-149 states the opposite as the invariant:

  // Stable batch number (2, 3, …) handed out at spawn and never reused or
  // shifted — the original dish is #1. 0 means "legacy/unnumbered" (persisted
  // before batch numbers existed); reconcile() assigns one on load.
  • Evidence: proof/01_findings/S1/facts_run.txt, probe D1 (PASS = defect confirmed) and defects_run.txt::S1-F5 (FAIL):
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. - Why it matters for a restaurant kitchen: the whole reason batchNo exists is that the journal is read back after service to work out which batch was late (engine.dart:127-131). Two different batches of fries writing ARRET Fries [lot 2] in the same log makes that reconstruction impossible, which is exactly the regression the 24/07 journal entry was written to close. - Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/engine/engine.dart:74-100
  • What is wrong: reconcile() drops orphan clones (:76-78), drops invalid run entries (:80-91), dissolves clones whose run entry went with them (:93), and assigns stable batch numbers to legacy entries (:97-99). It calls none of host.persistDefs(), host.persistRun() or host.persistClones(). Every one of those repairs exists only in memory until some unrelated user action happens to trigger a write. If the app is killed first — which is the normal end of a service on a kiosk tablet — the same corrupt records are read again on the next launch, and the batch numbers handed out on that launch are not the ones the journal recorded on this one.
  • Evidence: proof/01_findings/S1/facts_run.txt, probe D4 (PASS = defect confirmed):
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>. - Why it matters for a restaurant kitchen: the corrupt record that reconcile() exists to remove survives every relaunch, so the operator meets the same dropped timer on every boot, and lot numbers in the journal stop matching lot numbers on the tiles across a restart. - Proposed fix: track whether anything changed inside reconcile() and, when it did, call host.persistRun() and host.persistClones() once at the end. - How to prove the fix: 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

  • Severity: MEDIUM
  • Location: lib/engine/models.dart:66, and :22, :67, :68, :71, :72, :75, :134, :139, :156-158
  • What is wrong: id: j['id'] as String has no fallback, so a stored record missing id throws TypeError. The surrounding fields look defended — (j['name'] ?? 'Timer') as String — but the ?? only substitutes on null; the cast still runs on any non-null value of the wrong type and still throws. The same shape is repeated across all four factories. Ten separate wrong-type inputs were tested and all ten throw.
  • Evidence: proof/01_findings/S1/facts_run.txt, probes G1-G3 (PASS = every listed input throws):
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. - Why it matters for a restaurant kitchen: the failure is total for that dish and invisible until the cook looks for a timer that is no longer on the board. On a board seeded with the operator's own dishes, losing one to a single bad byte is a real cost. - Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/engine/engine.dart:177, :182, :232, :372-374, :386, :396; lib/engine/models.dart:68
  • What is wrong: startTimer computes n + t.durationSec * 1000 with no upper bound anywhere in the chain. saveDef's floors are lower bounds only. TimerDef.fromJson accepts any 64-bit integer. adjustTimer adds deltaSec * 1000 unchecked. When the product exceeds 2^63-1 the Dart int wraps to a negative value, the deadline lands before the moment the timer was started, and the very next tick fires the alarm.
  • Evidence: 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). - Why it matters for a restaurant kitchen: the observable symptom is a timer that rings the instant it is started and cannot be made to do anything else. The zero and negative cases (F3) are the more likely ones in practice — they need only a stored record with a missing or negative durationSec, which models.dart:68 defaults to 0 — and produce the same useless tile. - Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/engine/engine.dart:81-91 (the invariant block) and :333-335
  • What is wrong: reconcile()'s stated job is that "every run entry must hold its STRUCTURAL invariants" (:70-71). It checks two: running must have an endsAt (:84) and paused must have a remainingMs (:85). It checks nothing for ringing. But tick()'s ringing branch requires r.nextVoiceAt != null (:334) before it will repeat. A ringing entry restored without that field is accepted by reconcile(), renders on the board as a ringing dish, and never makes another sound.
  • Evidence: proof/01_findings/S1/restart_run.txt, probe K5 (PASS = defect confirmed):
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. - Why it matters for a restaurant kitchen: a tile that displays "ringing" while making no sound is the worst possible state: the cook's attention is bought by the alarm, not by the screen, and the screen is now lying. - Proposed fix: 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 the fix: 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.


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

  • Severity: MEDIUM
  • Location: lib/engine/engine.dart:50, documented at :34-49
  • What is wrong: alarmLeadMs is the single most consequential number in the product — twenty lines of comment justify it from a measured service journal — and no test pins its value. Every assertion that touches it computes the expectation from the constant itself (test/engine_test.dart:61,145,166,172, test/robustness_test.dart:291), so the expectation moves with the code and can never disagree with it.
  • Evidence: proof/01_findings/S1/mutations/MUT-6_alarmlead_zero.patch sets the constant to 0:
-  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. - Why it matters for a restaurant kitchen: the value that decides whether the cook hears the dish named before or after it is done is unprotected. A refactor, a merge or a well-meant "round it to 1000" ships green and changes the feel of every alarm on the board. - Proposed fix: 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 the fix: 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-F11 — reorder is asymmetric: dragging forward drops after the target, dragging back drops before it

  • Severity: LOW
  • Location: lib/engine/engine.dart:418-426
  • What is wrong: reorder removes the dragged definition and then inserts at the target's original index. After the removal every index above from has shifted down by one, so a forward drag lands one slot further right than the target while a backward drag lands exactly on it.
// engine.dart:422-423
    final m = timers.removeAt(from);
    timers.insert(to, m);
  • Evidence: 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. - Why it matters for a restaurant kitchen: the board's tile order is how a cook finds a dish without reading it. A drag that lands one slot past where it was dropped means a second drag, during service, on a greasy screen. - Proposed fix: 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 the fix: 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

  • Severity: LOW
  • Location: lib/engine/engine.dart:191-200
  • What is wrong: the method's own contract at :189 is "clone an ACTIVE dish". It checks that the parent definition exists (:192-193) and that the batch cap is not reached (:194), and never checks that the parent has a run entry. startTimer at :198 then starts the clone regardless, so the board shows an idle parent tile with a running batch beneath it.
  • Evidence: 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. - Why it matters for a restaurant kitchen: a batch labelled Fries [lot 2] with no Fries running above it reads as a board bug and costs the cook a moment of doubt about whether the board is trustworthy. - Proposed fix: 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 the fix: s1_defects_test.dart::S1-F12 — currently red. It requires e.spawnClone('p') to return null for a definition that was never started.


Where the implementation and the README disagree

README.md was last updated 2026-07-22 (README.md:3); the engine header comments record changes through v0.4.11 and v0.4.12.

README Implementation
README.md:13 — "escalade d'alarme 7 s ×0.72 plancher 2 s" Agrees exactly. Measured sequence, probe E1: 7000, 5040, 3629, 2613, 2000, 2000, …; 30 consecutive repeats all >= 2000; the floor is never crossed by rounding. firstVoiceGapMs = 7000 (:52), voiceGapFactor = 0.72 (:53), minVoiceGapMs = 2000 (:54), applied at :293 as math.max(minVoiceGapMs, (r.voiceGap * voiceGapFactor).round()) — the .round() produces 1881 at the fifth step and math.max lifts it back to 2000, so the integer path cannot go under.
README.md:13 — "batchs ×N (cap 3)" Agrees. maxBatch = 3 (:31) is counted as one original plus two clones (batchCount at :165-166 returns 1 + clones.length), so the cap admits exactly two duplicates. Verified by probe D2.
README.md:13 — "tick 150 ms, timestamps absolus" Agrees. tickMs = 150 (:32); probe H1 runs 2 000 frozen ticks and 1 000 irregular 37 ms ticks and endsAt is bit-identical throughout. Nothing in either file decrements a duration: remainingMs is only ever assigned from endsAt - now (:247) or from itself plus a delta (:235), and is set to null on resume (:260).
README.md:12 — "models.dart — schéma de données identique au proto (timer / zone / run / clone)" Stale. models.dart:6 — "v0.4.11 — ZONES ARE GONE." The only survivor is legacyZoneId (models.dart:39), which fromJson reads at :78 and toJson deliberately never writes (:56-63), for the one-time migration. There is no zone in the schema.
README.md:18 — "test/engine_test.dart — 17 tests unitaires" Stale. The file declares 21 tests; flutter test test/engine_test.dart reports +21: All tests passed!.
README.md:32 — "Alarmes en arrière-plan / app tuée : pas encore branchées (packages alarm + flutter_local_notifications prévus)" Stale. lib/alarm_backstop.dart exists, uses flutter_local_notifications, and is driven from every persistRun() the engine issues (code map §2.7). The engine's own comment at :42-44 already treats the backstop as live.
models.dart:147-149 — "Stable batch number … never reused or shifted" Contradicted by the code it documents. See S1-F5: _nextBatchNo (engine.dart:156-159) derives the next number from the live clones only, so a number is reused once its batches stop. "Never shifted" is true and tested; "never reused" is false and untested.

Correct but untested — for stream S7 and Phase 4

Behaviour verified correct by this audit that the 123-test suite does not cover. Each line names the test that should exist; the probes in proof/01_findings/S1/s1_facts_test.dart and s1_restart_test.dart are ready to be adapted.

Behaviour (verified correct) Test that should exist
A chain suspended past its entire length fires exactly once and emits no step chime (probe C1). engine_test.dart:128 covers 3 of 4 boundaries and never past the end. Backgrounded 24 h on a [60,60,60] chain → fired == ['c'], steps empty, stepIndex == 2.
The alarm lead is not re-applied per chain step: a 3×300 s chain rings at 900000 - alarmLeadMs, not 900000 - 3×alarmLeadMs (probe K10). A golden-instant chain test asserting the final ring at start + 898800 with no reference to the constant.
A relaunch restores a ringing timer and resumes nagging without replaying onAlarmFire (probe K1). No test in the repo round-trips a run map through toJson/fromJson into a fresh Engine. Round-trip a ringing run map, reconcile(), one tick()repeated == ['a'], fired empty.
A relaunch with an already-passed deadline fires with the true lateness (probe K2: driftMs == 3301200 for a one-hour oversleep). Same round trip with the clock advanced past endsAtdriftMs == elapsed - duration + alarmLeadMs.
A chain's stepIndex, chain flag and endsAt survive the JSON round trip unchanged (probe K8). Assert all three across RunEntry.toJson/fromJson mid-chain.
driftMs is deliberately absent from toJson and therefore erased by a restart (probe K3). Assert toJson() has no driftMs key, so the omission stays intentional.
Stopping a parent leaves its batches running and reconcile() keeps them (probe K7). Assert run[clone] survives stopTimer(parent) and a following reconcile().
A persisted voiceGap below the floor self-heals to 2000 on the first repeat (probe K6). Restore voiceGap: 10 → first _alarmRepeat yields minVoiceGapMs.
Zero and negative step durations terminate the catch-up loop; there is no hang (probe C2). The loop is bounded by step count, not by time. chain([0,0,0]) and chain([-1000000, 5]) both fire on the first tick.
A 1-step definition is not a chain: isChain false, totalSec returns durationSec and ignores the lone step, startTimer takes the single branch (probe C3). Assert isChain, totalSec and run.chain for a steps.length == 1 definition.
A leftover remainingMs on a running entry is ignored by tick()endsAt wins (probe K9). Assert the contradictory pair does not fire early.
pauseTimer and adjustTimer are no-ops while ringing and write nothing (probe H3). Assert persistRun count unchanged after both calls on a ringing entry.
reconcile() accepts a ringing entry whose endsAt is null (probe K4) — the correct counterpart to S1-F9. Assert the entry survives.
_fireAlarm clears armedAt (:282). MUT-3 deletes that line and the suite stays green. Assert run[id].armedAt == null after firing.
reconcile()'s legacy-renumber guard is c.batchNo < 2, so a clone stored as batchNo: 1 is rewritten to 2 (probe D3). MUT-2 changes the boundary to < 1 and the suite stays green. Assert a batchNo: 1 clone becomes 2 and a batchNo: 3 clone is left alone.
deleteDef, removeClonesOf, parentIdOf, isClone, copyWithId, uid() and all four fromJson factories have no direct test reference anywhere in test/ (grep -rl over the 13 test files returns nothing for each). One test per symbol; deleteDef matters most — it is the destructive path and is exercised only indirectly.

Coverage manifest

File Lines What was checked
lib/engine/engine.dart 432 Read in full. Every constant (maxBatch 31, tickMs 32, alarmLeadMs 50, dblMs 51, firstVoiceGapMs 52, voiceGapFactor 53, minVoiceGapMs 54) checked against the README and against measured behaviour. Every read and write of endsAt, remainingMs, armedAt, rangAt, driftMs, voiceGap and nextVoiceAt traced across all 12 sites (177, 182, 232-235, 247-248, 258-260, 276-284, 293-294, 312, 318-324, 335). Every public method executed by probe: uid (via spawnClone), soundFor, reconcile (D3, D4, G5, K4, K5, K7), viewList (D5, K7), labelFor (D1), batchNoFor (D3), nextBatchNo/_nextBatchNo (D1), isClone/parentIdOf/batchCount (D2), startTimer (all), spawnClone (D1, D2, D5), removeClonesOf (via saveDef), stopTimer (D1, K7), adjustTimer (F2, H2), pauseTimer/resumeTimer (H2, H3), _fireAlarm (A1-A3, B1-B2), _alarmRepeat (E1, E2, K6), tick (all), saveDef (F4, I1, I2), deleteDef (read only — no probe), reorder (J1), firstOrNull (via labelFor). The try/catch at 305/338 was attacked from three directions (corrupt entry, throwing callback, throwing persist). Six mutations applied and the full 123-test suite run against each. Not probed: deleteDef beyond reading it (its logic is onStopped + removeClonesOf + two removeWhere, all covered individually), and dblMs (51), which the engine declares but never reads — it is consumed by the UI layer.
lib/engine/models.dart 160 Read in full. All four toJson/fromJson pairs round-tripped and attacked with 10 wrong-type and 2 missing-field inputs (probes G1-G5, and roundTrip() in s1_restart_test.dart which reproduces the store's exact toJsonMap<String,dynamic>.fromfromJson path). Conditional-key writes in RunEntry.toJson (120-127) checked against fromJson's defaults (130-141) field by field, including the deliberately unpersisted driftMs (K3) and the voiceGap literal 7000 duplicated at 114, 139 and engine.dart:52. isChain (52) and totalSec (53-54) checked at lengths 0, 1 and ≥2 (C3). copyWithId (81-88) checked for the shared steps reference. legacyZoneId confirmed read at 78 and never written at 56-63. Force-unwraps at 52, 54 and 62 confirmed unreachable when isChain gates them. RunStatus coercion of an unknown string (131-132) probed (G4). Nothing in this file was left unexecuted.
S1 — adversarial refutation (stream S1: timer engine correctness)agent_reports/S1_refute.md · raw .md

S1 — adversarial refutation (stream S1: timer engine correctness)

Refuter: independent fresh-context agent. Governing rule: R5. Default under uncertainty: REFUTED. Subject: the app repository at 03a176e72ef0075eec86b8915cbe6e93042a3b9d, read-only. Scope reviewed: lib/engine/engine.dart (432 lines), lib/engine/models.dart (160 lines). Experiments on a fresh git archive copy at a scratch working copy — engine.dart sha256 9bfd65edb2c014c87cec9c117f1e34463a5fe6bebd186409247d816b382791b5, models.dart sha256 6565b792df8a2a58df4fd6a60f4f3c98042b12ffe121f2a6b0ca64e7afaa3cc2, both byte-identical to the subject. The pinned repo was never written to.

Result: 12 CONFIRMED, 0 REFUTED. 2 severities changed (S1-F2 and S1-F3, HIGH → MEDIUM). S1-F1 SURVIVES as a BLOCKER. The alarm backstop does NOT mitigate it. 3 missed findings contributed, plus 3 proof-integrity defects in S1's own artefacts, plus 1 refuted fix (S1-F1's proposed repair is itself defective and would break the app's sleep-survival property).


1. Proof artefacts produced by this refutation

All recorded through proof/run_and_record.sh [not published] into proof/01_findings/S1_refute/. Every one carries TREE_STATE: CLEAN against REPO: the app repository at GIT_HEAD: 03a176e… (I set CADENCE_REPO explicitly — see §6, PROOF-1, for why S1's did not).

Artefact Content
s1r_refute_test.dart + refute_probes_run.txt 18 refutation probes, all PASS (+18: All tests passed!)
s1r_persist_test.dart + redundant_persist_run.txt 3 probes for missed finding MISS-3, all PASS
s1_probes_reproduced.txt S1's own three probe files re-run on my clean copy: +33, +10, and the defects file's failing set
baseline_suite_clean.txt the stock 13-file suite on my copy: +123: All tests passed!
remut_MUT6_alarmlead_zero.txt, remut_MUT4_tickms.txt, remut_MUT1_positive_control.txt three of S1's six mutations re-applied and re-run independently
dart_timer_monotonic_source.txt, dart_timer_monotonic_android.txt Dart SDK C++ source proving Timer.periodic's clock
dart_stopwatch_clock_source.txt Dart SDK C++ source proving Stopwatch's clock
flutter_local_notifications_alarmclock.txt upstream Java proving the backstop's AlarmManager mode
captures/developer_android_SystemClock.txt, captures/developer_android_AlarmManager.txt official Android docs, captured with utilities/chrome.py per R4, retrieved 2026-08-04
s1_proof_tree_state_audit.txt the TREE_STATE audit of S1's own proof files
host_callback_sync_throw_audit.txt the synchronous-throw enumeration behind the S1-F2/F3 downgrades

2. S1-F1 attacked on all five axes

Axis 1 — does the probe faithfully model the real engine?

Yes. I read s1_facts_test.dart line by line rather than trusting its summary and re-ran it.

The probe's Host.now() returns a settable field; production's is int now() => DateTime.now().millisecondsSinceEpoch (lib/ui/home.dart:248) — verified, the citation is correct. The probe drives Engine.tick() in a loop advancing h.t by Engine.tickMs, which is exactly what Timer.periodic(const Duration(milliseconds: 150), … engine.tick() …) (lib/ui/home.dart:154-166) does. Between tick() and the clock there is no guard of any kind: tick() reads host.now() once at :301 and every decision is n < r.endsAt! - alarmLeadMs (:312, :318, :324, :335). No production code path adds a check the probe skips — I traced every caller of tick() (home.dart:165 heartbeat, home.dart:188 on resumed) and neither inspects the clock beyond a freeze log (see below).

The one production element the probe omits works against the engine, not for it: the heartbeat's own freeze detector at lib/ui/home.dart:157-162 is if (_foreground && gap > 1500). A backward step makes gap negative, so the detector stays silent. The app's only clock-sanity instrument cannot see the event.

Re-run on my clean copy: all 33 facts probes pass, all 10 restart probes pass, and the defects file fails on S1-F1 and S1-F1b exactly as recorded (s1_probes_reproduced.txt).

One correction. The Evidence code block printed under S1-F1 in findings/S1_engine.md is not verbatim. It stitches line 93 of the probe (final t = single('a', 300);) onto lines 110-120 of a different half of the same test, adds two comments that appear nowhere in the source (// a five-minute dish), silently alters a third (// operator / NTP steps the tablet clock back one hour → drops "tablet"), and prints e2.startTimer(t2); immediately after final t = … so the block references an undeclared identifier. The behaviour it describes is real and reproduces; the quotation does not exist in any file. That violates R2 ("verbatim quoted code block") and R9 ("quote code verbatim"). See §6, PROOF-3.

Axis 2 — is a backward clock step actually reachable on an Android tablet?

Yes, and it is officially documented as the expected behaviour of the clock the engine uses.

developer.android.com/reference/android/os/SystemClock, retrieved 2026-08-04, capture at proof/01_findings/S1_refute/captures/developer_android_SystemClock.txt:497:

System.currentTimeMillis() is the standard "wall" clock (time and date) expressing milliseconds
since the epoch. The wall clock can be set by the user or the phone network (see
setCurrentTimeMillis(long)), so the time may jump backwards or forwards unpredictably. This clock
should only be used when correspondence with real-world dates and times is important, such as in a
calendar or alarm clock application. Interval or elapsed time measurements should use a different
clock.

Same page, line 501:

elapsedRealtime() and elapsedRealtimeNanos() return the time since the system was booted, and
include deep sleep. This clock is guaranteed to be monotonic, and continues to tick even when the
CPU is in power saving modes, so is the recommend basis for general purpose interval timing.

DateTime.now().millisecondsSinceEpoch on Android is System.currentTimeMillis(). The engine measures a cooking interval with the clock Android explicitly tells you not to use for intervals, and Android explicitly states that clock jumps backwards. This is not a hypothetical.

On step size, the honest split, because it decides the severity:

  • A settled device with automatic time on receives small NTP corrections. My probe R1b measures the cost of a 200 ms backward step: the ring is one 150 ms beat late. That alone is not a BLOCKER, and if 200 ms were the realistic worst case I would say so and downgrade.
  • It is not the worst case. The doc says "set by the user or the phone network". The large steps are (a) an operator setting the date — which requires turning automatic time off, deliberate but entirely ordinary on a kiosk tablet showing the wrong time, and (b) the first sync after a boot on a device whose RTC ran fast or lost its cell. Both produce steps of minutes to hours.
  • The engine's exposure is linear in the step: probe R1a measures, for backward steps of 200 ms, 5 s, 60 s and 3 600 s, that the ring is late by exactly the step size to within one beat. There is no threshold below which the engine copes and above which it fails; a one-hour step buys one hour of silence on a five-minute dish, which is a burnt dish and then a fire.

One correction to S1's prose. The finding's title is accurate ("for the length of the step") but its body reads stronger: "it does not fire at all". Probe R1c measures that the alarm is not lost permanently — the wall clock re-crosses the stale deadline and the dish rings, once, late by the step. The failure is bounded silence, not a lost alarm. That distinction does not change the verdict (bounded silence of an hour on a five-minute dish is a failure to ring for every purpose that matters in a kitchen) but the report should state the measured behaviour rather than the stronger one.

Axis 3 — does the alarm backstop rescue this on Android?

No. It fails identically, for the same reason, and I could not find a branch that does not. This was the axis most likely to overturn the finding, so I chased it to the OS API.

The backstop schedules on the same wall-clock epoch value the engine uses: lib/alarm_backstop.dart:190tz.TZDateTime.fromMillisecondsSinceEpoch(tz.UTC, at + _graceMs), where at comes from r.endsAt at :107. And the schedule mode is AndroidScheduleMode.alarmClock (:193).

The chain from that mode to the OS clock, proven at each link:

  1. flutter_local_notifications 22.1.0 (pubspec.yaml:17), upstream ScheduleMode.java: alarmClockuseAlarmClock() returns true (proof/01_findings/S1_refute/flutter_local_notifications_alarmclock.txt).
  2. FlutterLocalNotificationsPlugin.java:769-771 and :787-789: } else if (notificationDetails.scheduleMode.useAlarmClock()) { … AlarmManagerCompat.setAlarmClock(alarmManager, epochMilli, pendingIntent, pendingIntent); }. Every sibling branch in the same two methods is AlarmManager.RTC_WAKEUP (:768, :773, :786, :792) — including the inexact branch the app degrades to at alarm_backstop.dart:194.
  3. developer.android.com/reference/android/app/AlarmManager, retrieved 2026-08-04, captures/developer_android_AlarmManager.txt:1039: "This method is like setExact(int,long,PendingIntent), but implies RTC_WAKEUP." And :876-878: RTC_WAKEUP = "Alarm time in System.currentTimeMillis() (wall clock time in UTC), which will wake up the device when it goes off."

So the OS safety net is anchored to the identical wall clock. Step that clock back one hour and the scheduled alarm moves one hour further away in real time, exactly like the in-app deadline. Nothing in the app requests ELAPSED_REALTIME_WAKEUP, the monotonic alternative Android documents at :810-820. S1's "no alarm at all" claim is therefore not overstated by ignoring a mitigation — the mitigation is subject to the same defect. (I did not audit alarm_backstop.dart otherwise; that is S3's scope. This is the single question my brief required me to settle.)

Axis 4 — is Timer.periodic wall-clock or monotonic in the Dart VM?

Monotonic, verified from SDK source rather than assumed. This matters because it decides whether the heartbeat keeps running during the stall.

  • sky_engine/lib/_internal/vm/lib/timer_impl.dart:205, 262, 365, 398 — every scheduling decision reads VMLibraryHooks.timerMillisecondClock().
  • sky_engine/lib/_internal/vm/bin/common_patch.dart:66VMLibraryHooks.timerMillisecondClock = _EventHandler._timerMillisecondClock;
  • runtime/bin/eventhandler.cc:109-113:
void FUNCTION_NAME(EventHandler_TimerMillisecondClock)(
    Dart_NativeArguments args) {
  int64_t now = TimerUtils::GetCurrentMonotonicMillis();
  Dart_SetReturnValue(args, Dart_NewInteger(now));
}
  • runtime/bin/utils_linux.cc (guarded #if defined(DART_HOST_OS_LINUX) || defined(DART_HOST_OS_ANDROID)), lines 72-87:
int64_t TimerUtils::GetCurrentMonotonicMillis() {
  return GetCurrentMonotonicMicros() / 1000;
}

int64_t TimerUtils::GetCurrentMonotonicMicros() {
  struct timespec ts;
  if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) {

The 150 ms heartbeat therefore keeps firing straight through a backward wall-clock step, tick() keeps running six times a second, and the silence is purely the arithmetic at engine.dart:312 failing. That is precisely the model the probe drives. This axis strengthens S1-F1: the app is awake, ticking, redrawing a countdown from the same wrong arithmetic, and silent.

Axis 5 — would the proposed fix work, and does it stay inside R6?

R6: yes. Correctness: NO — the proposed fix is defective and I am refuting it.

R6 first: adding int monotonicMs() to EngineHost and shifting deadlines is defect repair inside the existing state machine. No new user-facing capability. Compliant.

The defect. S1 specifies the monotonic source as "a process-lifetime Stopwatch, which is not derived from the wall clock". On Android, Dart's Stopwatch reads CLOCK_MONOTONIC:

  • sky_engine/lib/_internal/vm/lib/stopwatch_patch.dart:15-17@pragma("vm:external-name", "Stopwatch_now") external static int _now();
  • runtime/vm/os_android.cc:144-147:
int64_t OS::GetCurrentMonotonicTicks() {
  struct timespec ts;
  if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) {

CLOCK_MONOTONIC is the clock Android surfaces as uptimeMillis(), and the official doc, captures/developer_android_SystemClock.txt:499, says of it:

uptimeMillis() is counted in milliseconds since the system was booted. This clock stops when the
system enters deep sleep (CPU off, display dark, device waiting for external input) … This clock is
guaranteed to be monotonic, and is suitable for interval timing when the interval does not span
device sleep.

A kitchen tablet's timers routinely span device sleep. So during deep sleep the wall clock advances and the proposed detector's monotonic reference does not — and S1's rule, applied verbatim ("(now - lastNow) - (mono - lastMono) exceeds 2000 ms → shift endsAt … by exactly that delta"), reads a pure suspend as a forward wall-clock step.

Probe R2b implements S1's rule literally and measures the consequence (proof/01_findings/S1_refute/refute_probes_run.txt, s1r_refute_test.dart:156-191):

hh.t += 24 * 3600 * 1000;          // device deep-sleeps 24 h
// hh.mono deliberately unchanged  // CLOCK_MONOTONIC stops in deep sleep
final delta = proposedFixShift(ee, hh, lastNow, lastMono);
expect(delta, 24 * 3600 * 1000,
    reason: 'a pure suspend is misread as a +24 h wall-clock step');
expect(ee.run['c']!.endsAt, deadlineBefore + 24 * 3600 * 1000);
ee.tick();
expect(hh.fired, isEmpty,
    reason: 'the dish that should have fired during sleep is now silent, '
        'and its deadline has been pushed a further 24 h out');

with the untouched engine as the control in the same test:

h2.t += 24 * 3600 * 1000;
e2.tick();
expect(h2.fired, ['c'],
    reason: 'today the sleep/kill/reboot survival property holds');

So the proposed fix converts the app's headline guarantee — engine.dart:2-4, "All timing uses ABSOLUTE timestamps … so the state survives sleep/kill/reboot", the property S1's own probe C1 verifies — into a silent miss, and it does so on the ordinary path (a tablet sleeping between services) rather than the exotic one. Probe R2a confirms the fix does work on a genuine backward step, so the idea is sound and only the clock source is wrong.

Corrected fix. The detector needs a monotonic clock that includes suspend — SystemClock.elapsedRealtime() on Android (CLOCK_BOOTTIME), documented at captures/developer_android_SystemClock.txt:501 as "include deep sleep … the recommend basis for general purpose interval timing". Dart exposes no such clock, so EngineHost.monotonicMs() must be backed by a platform channel on the native side (the app already owns two MethodChannels), with Stopwatch only as the desktop/test fallback. Everything else in S1's proposal stands: threshold well clear of the 150 ms beat, shift endsAt/armedAt/rangAt/nextVoiceAt by the delta, persist once, write one journal line. This remains defect repair under R6.

S1-F1 verdict

CONFIRMED. Survives as a BLOCKER. Under R13 a BLOCKER is anything that "fails to ring an alarm". The mechanism is verified in code, reproduced independently against a clean tree, documented by the platform vendor as the expected behaviour of the clock in use, is not masked by the OS backstop, and occurs while the app is awake and ticking. Three corrections attach to it: the Evidence block is not verbatim, the silence is bounded by the step rather than permanent, and the proposed fix is defective as written.


3. Per-finding verdicts

ID Claim (one line) Verdict Reasoning Severity
S1-F1 Absolute epoch deadlines, no monotonic clock, no jump detection → a backward wall-clock step silences every running timer CONFIRMED §2. Code verified at engine.dart:312, :177, :182, :11; home.dart:248 citation correct; probes reproduced on a clean tree; heartbeat proven monotonic so the app is awake and silent; backstop proven to run on the same RTC_WAKEUP wall clock; Android documents the backward jump. Corrections: evidence block non-verbatim; silence bounded by the step (probe R1c), not permanent; proposed fix refuted (probe R2b). BLOCKER — agreed
S1-F2 A throwing host callback makes tick() delete the timer it just set to ringing CONFIRMED (defect real) engine.dart:338-343 verbatim quote is exact; the try at :305 does enclose host.onAlarmFire(t) (:286) and host.onStepAdvance(...) (:328); probes B1/B3 reproduce. But S1 grades HIGH on a path it concedes is undemonstrated. I extended the enumeration to every synchronous statement in the four callbacks reachable from tick() (home.dart:252-258, :284-303, :306-312, :315-334) — Journal.log (journal.dart:133-141, guarded by _file == null, body is a List.add plus a .then registration), Store.saveRun_write (all-catching try, store.dart:134-146; its jsonEncode argument can only see ints/strings/bools from RunEntry.toJson), backstop.sync (alarm_backstop.dart:122-149, every leaf synchronous-collection or async), AlarmVolume.onRunChanged, sounds.ringtone/stepChime (_play is async), _announceIfStill (constructs a Timer). None can throw synchronously. R13 HIGH requires "wrong behaviour during service"; this cannot occur during service with today's host. HIGH → MEDIUM (changed)
S1-F3 tick()'s recovery path calls host.persistRun() again; a throwing persistRun escapes the loop CONFIRMED (defect real) engine.dart:342 is the unguarded re-invocation; :285 and :327 are the in-try calls; probe B2 reproduces and B2b measures the blast radius. Same reachability ceiling as F2: persistRun has three synchronous leaves and all three are internally guarded or async. Latent robustness defect, not service behaviour. HIGH → MEDIUM (changed)
S1-F4 saveDef discards the duration of a chain reduced to one step; the suite locks the loss in CONFIRMED Quoted block matches engine.dart:366-374 exactly. Probe R5-F4 reproduces durationSec == 5 from a 5 400 s step. modals.dart:505 (onTap: steps.length <= 2 ? null : …) is cited correctly. MUT-1 re-applied independently: suite goes red with exactly one failing test, test/robustness_test.dart "saveDef floors (audit F7) …" (remut_MUT1_positive_control.txt) — so the suite really does enforce the loss. MEDIUM — agreed
S1-F5 A retired lot number is handed out again, contradicting models.dart:147-149 CONFIRMED _nextBatchNo at engine.dart:156-159 folds over live clones only; stopTimer deletes at :219-223. Probe R5-F5 spawns and stops four times and measures ['p [lot 2]', 'p [lot 2]', 'p [lot 2]', 'p [lot 2]']. The model comment at models.dart:147-149 says "never reused". MEDIUM — agreed
S1-F6 reconcile() repairs persisted state and writes none of it back CONFIRMED Read engine.dart:74-100 in full: no host.persistDefs(), host.persistRun() or host.persistClones() anywhere in the method. Probe R5-F6 measures all three counters at 0 after a reconcile() that dropped a zombie run entry and renumbered a clone. MEDIUM — agreed
S1-F7 TimerDef.fromJson throws on a bad id; every ?? beside it is null-only CONFIRMED models.dart:66 is id: j['id'] as String, with no guard; the ten wrong-type inputs in probe G3 all throw, reproduced. test/robustness_test.dart:41-58 does exercise the missing-id case through the store's salvage and the salvage correctly drops the whole record. The RunStatus inconsistency at models.dart:131-132 (unknown status silently coerced to running) is real. MEDIUM — agreed
S1-F8 Nothing bounds a duration; durationSec * 1000 overflows into a past deadline CONFIRMED Probe R5-F8 reproduces endsAt < now and an immediate ring for durationSec = 9223372036854776. The reachable arm is the one S1 names: models.dart:68 defaults a missing/absent durationSec to 0, and 0/negative both ring on the first tick (probe F3). Sites cited at :177, :182, :232, :372-374, :386, :396 are all correct. MEDIUM — agreed
S1-F9 A ringing entry with no nextVoiceAt passes reconcile() and is then silent forever CONFIRMED engine.dart:81-91 checks runningendsAt (:84) and pausedremainingMs (:85) and nothing for ringing; tick() requires r.nextVoiceAt != null at :334. Probe R5-F9 runs 1 000 beats and measures zero repeats. MEDIUM — agreed
S1-F10 alarmLeadMs can be set to 0 and the whole suite stays green CONFIRMED I re-applied the mutation myself: remut_MUT6_alarmlead_zero.txt+123: All tests passed!, EXIT_CODE=0. remut_MUT4_tickms.txt (150 → 5000) → also +123. grep -rn "alarmLeadMs\|Engine.tickMs" test/ returns five sites, all of the form … - Engine.alarmLeadMs (engine_test.dart:61,145,166,172, robustness_test.dart:291) and zero references to Engine.tickMs anywhere in test/. The expectations move with the code. MEDIUM — agreed
S1-F11 reorder is asymmetric: forward drops after the target, backward drops before it CONFIRMED engine.dart:422-423 quoted exactly. Probe R5-F11 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 — citation verified. LOW — agreed
S1-F12 spawnClone will start a batch of a dish that is not running CONFIRMED engine.dart:191-200: parent-exists check at :192-193, cap check at :194, no run check; startTimer at :198 runs regardless. Contract at :189 says "clone an ACTIVE dish". Probe R5-F12 reproduces. LOW — agreed

Totals: 12 CONFIRMED, 0 REFUTED, 2 severities changed.

I found nothing to refute outright. Every file:line I checked is valid at 03a176e, every quoted code block except S1-F1's is byte-accurate, and every probe reproduces on an independently built clean copy. R5 says inventing a nit is more expensive than certifying honestly, so I am not manufacturing a twelfth-hour objection to pad this column; the substantive attacks I do have are recorded above and in §5-§6 instead.


4. What S1 got right that I expected to break and could not

Recorded because a refuter that only lists complaints is not showing its work.

  • The DST/timezone exclusion is genuinely proven, not assumed — DateTime.now().millisecondsSinceEpoch is a UTC instant and probe A4 pins it. I looked for a toLocal() in the deadline path and there is none.
  • engine.dart:312's - alarmLeadMs is applied once per chain, not per step — probe K10 measures the final ring at 900000 - alarmLeadMs, and I re-read :317-324 to confirm the loop chains from the scheduled boundary.
  • The escalation ladder matches the README exactly. Probe R5-F10 measures [7000, 5040, 3629, 2613, 2000, 2000]; the fifth term is (2613 * 0.72).round() == 1881, lifted to 2 000 by math.max(minVoiceGapMs, …) at :293. The integer path cannot cross the floor.
  • The "no decrementing, ever" invariant holds: 2 000 frozen ticks and 1 000 irregular 37 ms ticks leave endsAt bit-identical (probe H1).
  • S1's claim that dblMs (engine.dart:51) is declared and never read by the engine is correct — grep -rn "dblMs" lib/ test/ returns exactly engine.dart:51 and ui/home.dart:387.

5. Missed findings

Held to R2: file:line valid at 03a176e, verbatim code, and an executed probe.

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

  • Severity: MEDIUM
  • Location: lib/engine/engine.dart:86-89 (valid at 03a176e)
  • What is wrong: the structural-invariant block enforces the implication in one direction only. If a run entry says it is a chain, the def must be a chain and the step index must be in range. If the def is a chain and the run entry does not say so, nothing objects. 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 and no way for the cook to tell that the remaining twenty minutes were discarded. Reachability is the same class S1 accepts for F4, F7 and F9: a stored run record whose chain key is absent or holds a value other than 1/true, which RunEntry.fromJson (models.dart:133, chain: j['chain'] == 1 || j['chain'] == true) silently reads as false while the store's per-entry salvage keeps the record.
  • Evidence: the guard, verbatim:
// 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:

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. - Why it matters for a restaurant kitchen: the alarm arrives, so nothing looks broken — and it arrives twenty minutes early on a dish the cook then plates raw. A silent early ring is harder to catch than silence. - Proposed fix: 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 the fix: 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 — remainingMs is clamped on every live path and on none of the restore paths

  • Severity: MEDIUM
  • Location: lib/engine/engine.dart:85 (the invariant), :258 (the consumer); live clamps at :235 and :247
  • What is wrong: reconcile() accepts a paused entry whenever remainingMs is non-null and never looks at its sign. resumeTimer at :258 then computes endsAt = n + (r.remainingMs ?? 0), so a negative stored value produces a deadline before the resume instant and the dish rings on the next beat. The engine clamps this value everywhere it writes it itself — math.max(0, (r.remainingMs ?? 0) + deltaSec * 1000) at :235, math.max(0, r.endsAt! - host.now()) at :247 — which is what makes the gap on the read path an oversight rather than a policy. RunEntry.fromJson (models.dart:136) accepts any int.
  • Evidence: proof/01_findings/S1_refute/refute_probes_run.txt, probes R4a and R4b (PASS = defect confirmed), source at s1r_refute_test.dart:228-254:
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
  • Why it matters for a restaurant kitchen: a dish paused before service and resumed at the pass rings instantly and cannot be made to do anything else. The cook's only recourse is to stop it and retype the time, mid-service, from memory.
  • Proposed fix: 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 the fix: probe R4a inverted — assert e.run is empty after reconcile() on a paused entry with remainingMs: -5000. Red today, green after.

MISS-3 — one user action issues two full persist cycles, and one beat issues one per firing dish

  • Severity: LOW
  • Location: lib/engine/engine.dart:212 and :414 (delete), :212 and :403 (save), :285 (per-entry in tick)
  • What is wrong: deleteDef calls removeClonesOf(id), which ends with host.persistClones() and host.persistRun() at :211-212, then calls host.persistRun() again at :414. saveDef does the same via :380 then :403. In tick(), _fireAlarm calls host.persistRun() at :285 once per firing entry, so three dishes landing on the same 150 ms beat produce three cycles. Each cycle is a full jsonEncode of the run map plus a backstop.sync diff plus an alarm-volume reconcile (home.dart:252-258) — i.e. a burst of AlarmManager binder traffic, which is the exact cost alarm_backstop.dart:36-40 introduced a 300 ms debounce to avoid. This is R7 (DRY) as much as performance: the write is not idempotent-by-design, it is merely repeated.
  • Evidence: 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!
final before = h.persistRuns;
e.deleteDef('p');
expect(h.persistRuns - before, 2,
    reason: 'removeClonesOf (engine.dart:212) then deleteDef (engine.dart:414)');
  • Why it matters for a restaurant kitchen: on its own, nothing a cook sees. It matters because S1-F3's proposed repair is "set a _dirty flag inside the loop and issue exactly one guarded host.persistRun() after the loop", and that repair only fixes the tick() third of the problem while leaving deleteDef and saveDef double-writing. Fixing the three together is one change.
  • Proposed fix: 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 the fix: the three probes above, inverted to expect 1, 1 and 1. Red today (they measure 2, 2, 3), green after.

6. Proof-integrity defects in S1's own artefacts

These are R12 defects in stream S1's evidence, not defects in the subject app. I report them because my brief required checking TREE_STATE on every proof file I opened.

PROOF-1 — the one S1 proof file carrying a TREE_STATE line stamped the wrong repository. proof/01_findings/S1/defects_run.txt header:

CWD:        a scratch working copy
GIT_HEAD:   a90312237e4fe6201866fc6251f4304d7e0816b3
TREE_STATE: DIRTY (1487 path(s) modified)
REPO:       Claude

a903122 is the workspace root repo's HEAD, not the pinned subject 03a176e, and the 1 487 modified paths are the workspace's own dirt. Cause: a scratch working copy contains no .git, so run_and_record.sh's git rev-parse --show-toplevel climbed out of the copy and landed on Claude. The DIRTY stamp on that file therefore says nothing about the engine under test, and equally the header cannot certify the copy was unmutated. Evidence: proof/01_findings/S1_refute/s1_proof_tree_state_audit.txt. I avoided this by exporting CADENCE_REPO=the app repository on every one of my own runs, and every artefact I produced reads GIT_HEAD: 03a176e… / TREE_STATE: CLEAN / REPO: …/cadence-app. The harness should default CADENCE_REPO when the cwd's toplevel differs from the pinned target, rather than silently stamping an unrelated repository.

PROOF-2 — nine of S1's ten proof files carry no TREE_STATE line at all. baseline_suite.txt, facts_run.txt, restart_run.txt and all six mutations/MUT-*.txt were recorded with the pre-TREE_STATE harness. Consequence: no mutation output can be shown from its own header to have been produced against an otherwise-unmutated tree, which is exactly the hole R8(d) and the TREE_STATE stamp exist to close. I closed it by re-deriving the results rather than trusting them: remut_MUT6_alarmlead_zero.txt (+123, exit 0), remut_MUT4_tickms.txt (+123, exit 0) and remut_MUT1_positive_control.txt (exit 1, failing set = exactly test/robustness_test.dart: saveDef floors (audit F7) …) all reproduce S1's claims, and shasum -a 256 lib/engine/engine.dart after each revert returns the pristine 9bfd65ed…. S1-F10 and S1-F4 stand. The remaining three mutations (MUT-2, MUT-3, MUT-5) I did not re-run; the exact missing artefact is a re-run of each with a stamped clean tree, and the precise test that would flip them is whether the stock suite still exits 0 under each patch.

PROOF-3 — S1-F1's Evidence code block is not verbatim. Detailed in §2, Axis 1. The block as printed exists in no file: it merges two halves of the same test, invents two comments, alters a third, and references an undeclared t2. R2 requires "a verbatim quoted code block", R9 requires "quote code verbatim", and R1 bans fabrication. The underlying probe is real and reproduces, so this is a presentation defect, not a false finding — but on the report's only BLOCKER it is the block a reader will check first, and it must be replaced with s1_facts_test.dart:92-121 as written.


7. Per-file coverage manifest

R5 requires either missed findings or a manifest; this stream produced both.

File Lines What I checked, independently of S1
lib/engine/engine.dart 432 Read in full at 03a176e against a git archive copy with a matching sha256. Every file:line cited in all 12 findings re-opened and verified:11, :31-32, :34-50, :51-54, :74-100, :81-91, :86-89, :97-99, :116-125, :132-138, :144-145, :156-159, :170-187, :177, :182, :189-200, :203-213, :215-226, :228-241, :232, :235, :243-251, :247, :253-263, :258, :265-287, :276-285, :289-296, :293, :300-345, :305, :312, :317-329, :333-343, :355-406, :366-374, :403, :408-416, :414, :418-426, :430-432. The clock path (:301:312/:318/:324/:335) traced end to end and cross-checked against both callers of tick() in lib/ui/home.dart (:165, :188) and against the freeze detector at home.dart:157-162, which is blind to a backward step. The reconcile() invariant block attacked from both directions of the chain implication (MISS-1) and on the sign of remainingMs (MISS-2). Persist accounting measured at every site (MISS-3). Constants re-measured: alarmLeadMs 1200, tickMs 150, firstVoiceGapMs 7000, voiceGapFactor 0.72, minVoiceGapMs 2000, maxBatch 3, dblMs 260 (declared here, read only at home.dart:387). Three of six S1 mutations independently re-applied and reverted, with the file sha256 verified pristine afterwards. Not re-derived: the code map's structural inventory (used as directed, not rebuilt); mutations MUT-2, MUT-3, MUT-5 (named as the missing artefact in §6).
lib/engine/models.dart 160 Read in full at 03a176e. All four fromJson factories re-checked field by field against their toJson counterparts: StepDef (:20-22), TimerDef (:56-79), RunEntry (:118-141), CloneRef (:153-159). Confirmed independently that id: j['id'] as String (:66) and CloneRef's :156-157 are the only unguarded casts, that every ?? at :22, :67, :68, :71, :72, :134, :139, :158 is null-only so a wrong type still throws through the cast, and that RunEntry.fromJson's status (:131-132) is the sole read that silently defaults instead of throwing. chain: j['chain'] == 1 \|\| j['chain'] == true (:133) is the reachability hinge for MISS-1. remainingMs: j['remainingMs'] as int? (:136) accepts any sign — the hinge for MISS-2. isChain (:52) and totalSec (:53-54) checked at 0, 1 and ≥2 steps: a one-step def is not a chain and totalSec returns durationSec, ignoring the step — consistent with tile.dart:183 and home.dart:359, so the only defect in that area is saveDef discarding the step (S1-F4), not the model. copyWithId (:81-88) shares the steps list by reference, and saveDef mutates caller-supplied StepDef.sec in place at engine.dart:368 — noted, no reachable consequence found through the engine. driftMs deliberately absent from toJson (:118-128); legacyZoneId read at :78, never written. The contract comment at :147-149 ("never reused or shifted") is the one the code contradicts (S1-F5). Nothing in this file was left unread.

8. Verdict on S1-F1

S1-F1 survives as a BLOCKER.

It survived every axis I attacked it on. The probe models the production path faithfully and adds no convenience the real engine lacks — I read its source rather than its summary and re-ran it on a clean tree. The trigger is not exotic: Android's own reference documentation states that the clock the engine measures cooking intervals with "may jump backwards or forwards unpredictably" and tells developers to use a different clock for interval timing. The heartbeat is monotonic (clock_gettime(CLOCK_MONOTONIC), proven from SDK source), so during the stall the app is awake, ticking six times a second, redrawing a countdown, and mute — the worst of the possible failure shapes. And the OS backstop cannot mask it: AndroidScheduleMode.alarmClock resolves to AlarmManager.setAlarmClock, which Android documents as implying RTC_WAKEUP, the same wall clock, and every fallback branch in the plugin is RTC_WAKEUP too. Under R13 — "BLOCKER = … fails to ring an alarm" — a five-minute dish that stays silent for the length of an hour-long backward step is a failure to ring.

Three things must change in the finding before it ships, none of which touches the severity:

  1. Replace the Evidence block with the verbatim text of s1_facts_test.dart:92-121 (PROOF-3).
  2. State the measured behaviour — silence bounded by the size of the step, then a single late ring (probe R1c) — instead of "it does not fire at all", and give the measured linearity from probe R1a so the reader can see that a 200 ms NTP slew costs one beat while a manual date correction costs the whole step.
  3. Replace the proposed fix's clock source. A Dart Stopwatch reads CLOCK_MONOTONIC, which Android documents as stopping in deep sleep; S1's rule applied to it misreads a 24-hour suspend as a +24-hour wall-clock jump and pushes every live deadline a further 24 hours out, destroying the sleep/kill/reboot survival property the engine's own header claims and S1's own probe C1 verifies (probe R2b, with the unfixed engine as the control in the same test). The detector needs a suspend-inclusive monotonic clock — SystemClock.elapsedRealtime() / CLOCK_BOOTTIME — which Dart does not expose, so EngineHost.monotonicMs() must be backed by a platform channel with Stopwatch as the desktop/test fallback. The rest of the proposal is correct and stays inside R6.

Stream S2: finding and refutation

S2 — Persistence, journal and diagnosticsfindings/S2_persistence.md · raw .md

S2 — Persistence, journal and diagnostics

Scope: lib/engine/store.dart (354 lines), lib/journal.dart (250 lines), lib/diagnostics.dart (54 lines). Pinned commit 03a176e72ef0075eec86b8915cbe6e93042a3b9d, version 0.4.12+18.

All work was done on a read-only copy at a scratch working copy (R10). The subject repo was never modified; git rev-parse HEAD on both was verified identical before and after.

20 findings: 2 BLOCKER, 7 HIGH, 9 MEDIUM, 2 LOW. Plus two closing sections: S2-C1, a concurrency area checked with no defect found, and S2-C2, a MEDIUM cross-reference in models.dart reachable only through Store.load.

Probe suites written for this stream (5 files, 38 tests), stored alongside their recorded output in proof/01_findings/S2/:

Probe Recorded run Tests
s2_corruption_matrix_test.dart proof/01_findings/S2/01_corruption_matrix.txt 7
s2_migration_test.dart proof/01_findings/S2/02_migration.txt 11
s2_journal_diag_test.dart proof/01_findings/S2/03_journal_diagnostics.txt 12
s2_write_concurrency_test.dart proof/01_findings/S2/04_write_concurrency.txt 6
s2_journal_growth_test.dart proof/01_findings/S2/08_journal_growth.txt 2

With all five added the suite is 161 passed, 0 failed (proof/01_findings/S2/09_full_suite_final.txt) and flutter analyze --fatal-infos --fatal-warnings exits 0 (proof/01_findings/S2/10_analyze.txt). No existing test was modified or broken.


Data-loss blast radius (requested separately — for S13 and Phase 4)

Measured by seeding SharedPreferences.setMockInitialValues with each bad value over a baseline of 3 valid dishes and 3 running entries, then calling Store.load(). Raw output: proof/01_findings/S2/01_corruption_matrix.txt.

# Corruption class Value seeded Records lost .corrupt backup taken Operator banner
A0 control, clean JSON valid array none (3/3 kept) n/a n/a
A1 malformed JSON {{{not json ALL (0/3) yes yes
A2 truncated JSON (torn write) first 60 bytes of a valid array ALL (0/3) yes yes
A3 map where an array is expected {"timers": "not-an-array"} ALL (0/3) yes yes
A4 JSON null literal null ALL (0/3) yes yes
A5 one element is a bare int [{…}, 42, {…}] ONE (2/3 kept) yes yes
A6 one element is null [{…}, null, {…}] ONE (2/3 kept) yes yes
A7 one element missing id [{…}, {"name":…}, {…}] ONE (2/3 kept) yes yes
A8 one element field wrong type durationSec: "sixty" ONE (2/3 kept) yes yes
A9 one element steps not a list steps: "nope" ONE (2/3 kept) yes yes
A10 wrong prefs TYPE int 42 under a String key ALL (0/3) NO NO
A11 wrong prefs TYPE bool true under a String key ALL (0/3) NO NO
A12 wrong prefs TYPE List<String> under a String key ALL (0/3) NO NO
B1–B3 bad JSON under cadence-run-v1 as A1–A3 ALL runs (0/3); timers untouched (3/3) yes yes
B4–B5 one bad run entry 'b': "not-a-map" / null ONE run (2/3 kept) yes yes
B6 unknown status string status: "exploded" none dropped — silently coerced to running no no
B7 wrong prefs TYPE on run key int 42 ALL runs (0/3) NO NO
C1 bad JSON under cadence-clones-v1 {{{ ALL clones yes yes
C2 wrong prefs TYPE on clones key int 42 ALL clones NO NO
D1 every settings key wrong-typed lang: 3.14, vol: "loud", flags wrong nonelang→'en', vol→1.0 n/a no

Two structural conclusions:

  1. Entry-level salvage works exactly as advertised for JSON-level damage. One bad dish costs one dish, not twelve. That design goal is met and proven (rows A5–A9, B4–B5).
  2. The salvage is bypassed entirely when the damage is at the prefs TYPE level (rows A10–A12, B7, C2). There the loss is total, no backup is written, and nothing reaches the operator. That gap is S2-F1.

Corruption of cadence-timers-v1 never damages cadence-run-v1 or cadence-clones-v1 and vice versa: each key is decoded independently (store.dart:64, 65, 67). Cross-key blast radius is zero in every row tested.


Findings

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

  • Severity: BLOCKER
  • Location: lib/engine/store.dart:34-41 and lib/engine/store.dart:93 (valid at 03a176e)
  • What is wrong: _readString catches the TypeError that prefs.getString throws when the stored value is not a String, reports it as non-critical, and returns null. _readList then treats null as "key absent" and returns [] at line 93 — before _preserveCorrupt is ever reached. The result is that the one corruption class where the raw bytes are still intact and perfectly recoverable is the only class where no backup copy is taken and no operator banner is raised. The board comes up empty, the operator is told nothing, and the first subsequent persistDefs() writes [] over the key, destroying the last copy. Every other corruption class goes through _preserveCorrupt (store.dart:119-128) which reports isCritical: true and copies the raw value aside first.
  • Evidence: 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 _readList(String key, T Function(Map) 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 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 testrobustness_test.dart:79("wrong TYPES under our keys → boot survives with fallbacks") asserts onlyexpect(e.timers, isEmpty). It asserts the data loss as the correct outcome and never checks for preservation or a banner. - **Why it matters for a restaurant kitchen:** the kitchen walks in, opens Cadence, and the board is blank. Twelve dishes with their durations, chains, tones and announcements are gone, no warning is shown, and by the time anyone notices the recoverable bytes have already been overwritten with[]. Every other corruption class in the matrix at least lights the banner. - **Proposed fix:** in_readString/_readBool/_readDouble, distinguish "absent" from "wrong type". Give_readStringan out-parameter or a sentinel (e.g. return a({String? value, bool typeError})record), and in_readListand the run block call_preserveCorrupt(key, '', err)on the type-error branch so the raw value is copied aside viaprefs.get(key).toString()and the failure is raisedisCritical: true, matching the JSON path. - **How to prove the fix:**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 toexpect(store.prefs.getString('cadence-timers-v1.corrupt'), isNotNull)andexpect(Diag.critical.value, contains('load-cadence-timers-v1'))`. Red now, green after.


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

  • Severity: BLOCKER
  • Location: lib/engine/store.dart:255-285; the false claim is at lib/engine/store.dart:249-252; the mechanism is lib/engine/models.dart:56-63 (valid at 03a176e)
  • What is wrong: the doc comment asserts "Idempotent by construction: it re-reads the same zones and assigns the same tones, so dying before the flag is written costs nothing." That is false the moment the migration's own saveDefs(e) (line 280) lands. TimerDef.toJson deliberately omits zoneId (models.dart:56-63, "Not serialised: once migrated, the notion must leave no trace"), so on the next load() every TimerDef.legacyZoneId is null. Line 274 then evaluates tones[null] ?? _legacyFallbackSound for every timer and assigns 'Bell' to the entire board. The step the comment cites as the safety guarantee — saving the timers first — is precisely what erases the key idempotency depends on. A second run is reachable because the one-shot flag write at line 282 goes through _guard, which never verifies the write and reports failure as non-critical (store.dart:174-180).
  • Evidence: 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. - **Why it matters for a restaurant kitchen:** the migration's entire stated purpose is *"operator must notice NOTHING: same dishes, same sounds, except the sound is now its own."* (`store.dart:246-247`, verbatim). A second run does the exact opposite — every station's tone collapses to one identical `Bell`. During service the cooks distinguish the fryer from the oven by sound alone; making all twelve dishes ring identically removes that discrimination completely, and the alarm still fires so nothing looks broken. - **Proposed fix:** 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 withdart 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:_guardshould report the_kZoneSoundfailure asisCritical: trueso a lost one-shot flag is visible. - **How to prove the fix:**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 toexpect(_sounds(boot2), _sounds(boot1))andexpect(_sounds(e)['Baklava'], 'Cascade')`. 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

  • Severity: HIGH
  • Location: lib/engine/store.dart:256-285; the contradicted invariant is at lib/engine/store.dart:260-261 (valid at 03a176e)
  • What is wrong: the code documents an explicit invariant — "Present but unreadable is NOT 'absent': rather than silently hand the whole kitchen a default tone, keep the legacy key and retry next boot." That invariant is enforced only for unreadable JSON (the catch at 269-272 returns 0 before the flag is written). It is not enforced for an unreadable prefs type: _readString swallows the TypeError and returns null, raw != null at line 259 is false, the whole inheritance block is skipped, and execution falls straight through to lines 282-283 which set the one-shot flag and prefs.remove(_kZones). The migration is now permanently marked complete, the only copy of the zone tones has been deleted, and every dish keeps kDefaultSound ('Chirp').
  • Evidence: 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
  • Why it matters for a restaurant kitchen: the upgrade to v0.4.12 flattens every station's chosen ringtone to one default and there is no second chance — the zone data is deleted in the same call. The kitchen loses a configuration it can only rebuild by hand, one dish at a time, and is never told.
  • Proposed fix: 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 the fix: 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.

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

  • Severity: HIGH
  • Location: lib/engine/store.dart:273-279 (valid at 03a176e)
  • What is wrong: the loop applies tones[t.legacyZoneId] ?? _legacyFallbackSound to every timer in the list, with no test for whether the timer predates zones at all. A timer created in v0.4.11 or later has legacyZoneId == null by construction, so it takes the ?? 'Bell' branch and its operator-chosen tone is overwritten. This is reachable in a single boot on any tablet where the one-shot flag write was lost (S2-F2) and the operator has since added dishes.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: dishes added since the upgrade — the newest items on the menu, the ones the kitchen is least practised at — silently change tone.
  • Proposed fix: the if (zid == null) continue; guard in S2-F2's fix resolves this finding too.
  • How to prove the fix: s2_migration_test.dart, test "DEFECT 4"; change the final expectation to expect(_sounds(e)['Baklava'], 'Cascade'). Red now, green after.

S2-F5 — A failed journal write discards the buffered lines permanently and tells nobody

  • Severity: HIGH
  • Location: lib/journal.dart:163-180, specifically 167-168 and 175-177 (valid at 03a176e)
  • What is wrong: _flush copies the buffer into chunk, clears _buf, and then attempts the write. If the write throws, the catch at 175 only calls debugPrintchunk goes out of scope and the lines are gone. They are not re-queued, Diag is never informed, no banner is raised, and Journal.ready still returns true. Since log() flushes on every single event (journal.dart:139), an intermittently unavailable filesystem drops individual events rather than degrading gracefully — and the events lost are whichever ones happened during the outage, which is exactly the window an investigator would want.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the journal is the team's only field-forensics tool. This drops exactly the alarm and acknowledgement records that a "the timer didn't ring" complaint turns on, and the exported log looks complete because there is no gap marker.
  • Proposed fix: 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.
  • How to prove the fix: 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.

S2-F6 — Journal rotation erases the entire journal when the file cannot be decoded

  • Severity: HIGH
  • Location: lib/journal.dart:192-203, triggered from lib/journal.dart:72 (valid at 03a176e)
  • What is wrong: _rotate reads the whole file with readAsString() (UTF-8, strict) and, on any failure, writes an empty string over it. A single invalid UTF-8 byte — which is what a torn append leaves behind, and torn appends are the specific failure this file exists to survive — makes readAsString throw FormatException, and the catch at 200 destroys every session ever recorded. An OutOfMemoryError from readAsString on a large file is caught by the same catch (_) with the same consequence. Nothing is reported. This entire method is uncovered at baseline (lcov: journal.dart lines 192, 194, 195, 196, 197, 199, 201 all DA:…,0).
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the one moment the journal is most likely to be malformed is straight after the kill it was built to record. Rotating at that moment throws away the evidence of the incident being investigated, and the app reports success.
  • Proposed fix: 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).
  • How to prove the fix: s2_journal_diag_test.dart, test "J3 DEFECT". Invert to expect(after, contains('a whole service worth of evidence')). Red now, green after.

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

  • Severity: HIGH
  • Location: lib/journal.dart:108-111 and lib/journal.dart:133-134 (valid at 03a176e)
  • What is wrong: if init fails for any reason it sets _file = null and calls debugPrint only — a call that is compiled out in release builds. log() then returns at line 134 on every subsequent call, so every event, every heartbeat, and every routed Diag.fail disappears. The failure choke point Diag.fail writes its journal line at diagnostics.dart:37 into a journal that is not there; the in-RAM Diag.log is never surfaced anywhere in production (S2-F17), so the operator's only forensic artifact is silently absent for the whole session. exportCopy returns null with no reason attached (journal.dart:208).
  • Evidence: 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. - **Why it matters for a restaurant kitchen:** the team ships a build, asks Serge to export the log after service, and gets an empty file or no file — with no way to distinguish "nothing went wrong" from "the recorder was never running". The pinned commit (v0.4.12 : le journal annoncait la mauvaise build) and the incident recorded intest/version_test.dart:1-8show this class of failure has already cost this team two investigations. - **Proposed fix:** in thecatchat 108, addDiag.fail('journal-init', e, isCritical: true);after_file = null.Diag.failis safe to call here — its ownJournal.logatdiagnostics.dart:37no-ops when_file == null. Add the same to theexportCopycatch at 233 (Diag.fail('journal-export', e)— note that scope string is already used byui/modals.dart, so reuse it). - **How to prove the fix:**s2_journal_diag_test.dart, test *"J2 DEFECT"*. Changeexpect(Diag.log.map((d) => d.scope).toList(), ['audio-init'])tocontainsAll(['journal-init', 'audio-init'])and assertexpect(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

  • Severity: HIGH
  • Location: lib/diagnostics.dart:29 (valid at 03a176e)
  • What is wrong: Diag.fail interpolates the error object with '$e' as its very first statement. If that object's toString() throws, Diag.fail throws — before anything is recorded in Diag.log, before the banner is set, and before the journal line is written. Because Diag.fail is called from inside catch blocks (store.dart:38, 47, 56, 120, 126, 137, 142, 145, 176, 178, and 31 further sites across lib/), the exception propagates out of the very handler that was supposed to contain it. From store.dart:145 it escapes _writesaveDefsEngineHost.persistDefsEngine.saveDef and into the UI callback. The file's own header claims the opposite: "no silently-swallowed failure … single choke point, so no call site can ever forget to record one".
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the single function whose job is to make sure nothing fails silently is itself an uncaught-exception path. A platform exception with a hostile toString during a save turns a recoverable storage warning into a crash mid-service.
  • Proposed fix: 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 the fix: 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

  • Severity: HIGH
  • Location: lib/engine/store.dart:300-310 (valid at 03a176e)
  • What is wrong: seedIfFresh decides an install is fresh from two signals: _readBool(_kSeeded) != true and e.timers.isEmpty && _readString(_kZones) == null. Both signals fail open. A wrong-typed cadence-seeded-v1 makes _readBool return null (store.dart:43-50), and a corrupt cadence-timers-v1 makes e.timers empty (S2-F1 and the JSON path alike). On any tablet already past the zone migration, _kZones has been removed (store.dart:283) so the second guard is gone too. The demo seed then runs and writes seven English demo dishes over the kitchen's board. The guard comment at 302-304 anticipates exactly this scenario but only defends it via the legacy zones key, which the migration itself deletes.
  • Evidence: 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]
  • Why it matters for a restaurant kitchen: the kitchen opens the app and finds someone else's menu. Combined with S2-F1 there is no banner explaining why, and the demo board is immediately persisted over the real keys.
  • Proposed fix: 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 the fix: 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.

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

  • Severity: MEDIUM
  • Location: lib/engine/store.dart:219-231 (valid at 03a176e)
  • What is wrong: the one-shot flag at line 229 is written through _guard, which never verifies it (store.dart:174-180). The doc comment at 215-216 states the exact hazard — "Runs once, ever — otherwise an operator who later types exactly our wording would see it cleared" — but the code relies on an unverified, non-critical write to enforce it. If that write is lost, the next boot re-runs the repair and deletes any announcement matching wasGeneratedByUs, including one the operator typed themselves. Note also that line 229 sets the flag unconditionally, even when the saveDefs(e) at line 228 fails.
  • Evidence: 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 ""
  • Why it matters for a restaurant kitchen: a cook types "les frites sont prêtes, service" and it silently reverts to the generated default on some later boot. The announcement is what the kitchen hears across the pass.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/engine/store.dart:264-268 (valid at 03a176e)
  • What is wrong: line 267 is if (id is String && s is String) tones[id] = s; with no else. A zone whose sound is a number, null, or an object is dropped from the tones map with no record; every timer that belonged to it then takes the ?? _legacyFallbackSound branch at line 274 and rings Bell. This is partial data loss inside a migration that documents "the operator must notice NOTHING", and unlike the whole-file JSON failure at 269-272 it raises no Diag entry at all.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: one station loses its tone and merges audibly into the default, and the operator gets no signal that anything was dropped.
  • Proposed fix: 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 the fix: 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 — The .corrupt preservation is write-only: nothing reads it, exports it, or restores from it, and a second corruption is discarded

  • Severity: MEDIUM
  • Location: lib/engine/store.dart:119-128 (valid at 03a176e)
  • What is wrong: _preserveCorrupt copies the raw value to a '$key.corrupt' sibling and the comment at 115-118 presents this as the guarantee that "Corrupt stored data is never silently lost". It is never lost, but it is also never reachable: the only reference to a .corrupt key anywhere in lib/ is line 122, which reads it purely to decide not to overwrite it. There is no reader, no UI, no export path — Journal.exportCopy copies the journal file, not the prefs — and therefore no way for the operator or the team to recover the data. Two secondary consequences: (a) because of the == null test at 122, a second, different corruption of the same key is discarded entirely; (b) the sibling is never removed once the board is healthy again, so it occupies the prefs store permanently.
  • Evidence: 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 `` - **Why it matters for a restaurant kitchen:** the kitchen's twelve-dish configuration is sitting intact inSharedPreferencesunder a key that no code path can reach, while the operator re-enters everything by hand. The recovery is a two-line feature away and is currently zero-value. - **Proposed fix:** in scope for this audit (R6 permits compliance/diagnostics plumbing, not new end-user features): append the.corruptpayloads 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.corruptkey 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.corruptsibling exists, which parses it with the same entry-level salvage and replaces the current list. - **How to prove the fix:** a new test seedingcadence-timers-v1with{{{corrupt, callingStore.load, and asserting the exported journal file containsDONNEE 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; the README's "écriture immédiate" is a claim about initiation only

  • Severity: MEDIUM
  • Location: lib/engine/store.dart:133-147 and lib/engine/store.dart:174-180; the claim is at README.md:14 (valid at 03a176e)
  • What is wrong: store.dart reports 94.40% line coverage, but the seven uncovered lines are all failure handlers, and five are specifically the write-failure branches: 137 (write returned false), 142 (.catchError on setString), 145 (_write outer catch), 176 (_guard returned false), 178 (_guard catchError). The remaining two are 47 (_readBool type error) and 126 (_preserveCorrupt inner catch). No test in the 123-test baseline ever exercises a failing write, so the disk-full and platform-error behaviour of the persistence layer — the behaviour the banner exists for — is entirely unproven. Separately, README.md:14 claims persistance shared_preferences (4 clés, écriture immédiate). "Immédiate" is accurate only as to initiation: _write calls prefs.setString synchronously on every mutation, but returns void with the future unawaited, so no caller can determine whether the write landed and a failure surfaces only later, asynchronously, through Diag.fail.
  • Evidence: 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".
  • Why it matters for a restaurant kitchen: a tablet with a full data partition accepts every edit in the UI, persists none of them, and the only evidence is a banner driven by code that has never been executed once. The kitchen discovers the loss on the next reboot, mid-service.
  • Proposed fix: 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 the fix: 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)

  • Severity: MEDIUM
  • Location: lib/engine/store.dart:280 (valid at 03a176e)
  • What is wrong: no test in the baseline suite asserts that migrateZoneSounds writes the inherited tones to storage. The migration group in store_test.dart:125-219 checks the in-memory e.timers after the call, checks the flag, and checks the legacy key removal — but never re-reads cadence-timers-v1. Deleting the saveDefs(e) call entirely leaves the suite green, meaning the migration could silently persist nothing and ship. This matters because the doc comment at store.dart:251-252 names that exact line as the safety guarantee.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: the migration is a once-per-tablet, unrepeatable operation on the kitchen's configuration. A regression in its persistence would reach the field with a green CI run.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/journal.dart:83, sourced from lib/main.dart:20 (valid at 03a176e)
  • What is wrong: the session header prints Cadence v$appVersion, where appVersion is kAppVersion = '0.4.12' — the semantic version only. pubspec.yaml:5 declares 0.4.12+18; the build number is dropped. test/version_test.dart guards kAppVersion against the pubspec semantic part and explicitly documents the omission: "le numero de build n'apparait pas dans l'en-tete du journal". Store rollouts ship multiple builds under one semantic version (a rejected submission resubmitted, a signing fix, a staged rollout), so 0.4.12+17 and 0.4.12+18 produce byte-identical headers. The pinned commit is v0.4.12 : le journal annoncait la mauvaise build and version_test.dart:1-8 records a prior incident where field journals from 29/07/2026 announced v0.4.9 on a tablet running v0.4.11. The version half of that problem is now guarded; the build half is not.
  • Evidence: 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"
  • Why it matters for a restaurant kitchen: two tablets in the same pilot running different builds of v0.4.12 produce indistinguishable logs, and an investigation into "it rang late on one tablet but not the other" has no way to tell which code each was running. This has already sent this team down the wrong code path twice.
  • Proposed 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 the 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 after ~34 days

  • Severity: MEDIUM
  • Location: lib/journal.dart:72 (valid at 03a176e)
  • What is wrong: the size check if (await f.length() > _maxBytes) await _rotate(f); runs exactly once, inside init. Nothing re-checks during a session. Cadence is a kiosk app — main.dart:28 enables a wakelock and the app is designed to stay foregrounded — so a tablet can run for weeks without a process restart. The 3 MiB cap is therefore an on-boot cap, not a size bound.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: a tablet left running from one service to the next for a month accumulates a journal that will be rotated by reading it entirely into memory on the next boot, on a device with little of it, and any failure in that read wipes the log (S2-F6).
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/diagnostics.dart:9-22 (valid at 03a176e)
  • What is wrong: Diag.log is a 50-entry ring buffer maintained on every failure, and DiagEntry.toString() formats an entry for display. Neither is consumed anywhere in lib/. The only readers are store_test.dart:104 and journal_test.dart:45. DiagEntry.toString() has no caller at all, which is why lines 15-16 are two of the three uncovered lines in the file (the third, line 20, is the unreachable private constructor). The buffer therefore costs an allocation and a removeAt(0) on every failure and returns nothing in the field: on a tablet, the operator's only artifact is the journal.
  • Evidence: 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].
  • Why it matters for a restaurant kitchen: the file header promises a "Bounded ring buffer (inspectable in tests / debug)". It is honest about "tests", but there is no debug surface, so a reader of this code reasonably believes there is an in-app failure list to consult during a field visit. There is not.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/diagnostics.dart:29 and lib/diagnostics.dart:37 (valid at 03a176e)
  • What is wrong: Diag.fail interpolates the error object with no length cap, stores the result in Diag.log, and appends it to the journal. The store's own failure reports embed stored data: store.dart:38 builds 'stored value has wrong type: $e', and _preserveCorrupt at store.dart:120 passes a FormatException whose toString() quotes the offending source. A corrupt multi-kilobyte timer blob therefore lands in the journal in full on every boot, and 50 such entries sit in RAM.
  • Evidence: 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
  • Why it matters for a restaurant kitchen: a single persistent corruption inflates the journal by its own size on every boot, pushing out the events that explain the incident and accelerating the rotation that can destroy the file (S2-F6).
  • Proposed fix: 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 the fix: s2_journal_diag_test.dart, test "D3 DEFECT". Invert expect(grew, greaterThan(190000)) to expect(grew, lessThan(1000)). Red now, green after.

S2-F19 — README.md claims 4 SharedPreferences keys; Store writes 10 in a single boot

  • Severity: LOW
  • Location: README.md:14; the keys are lib/engine/store.dart:15-24 and lib/journal.dart:23-24 (valid at 03a176e)
  • What is wrong: the architecture line reads store.dart — persistance shared_preferences (4 clés, écriture immédiate). Store declares nine key constants (_kDefs, _kRun, _kZones, _kZoneSound, _kClones, _kSeeded, _kPhraseRepair, _kLang, _kVol), writes up to three dynamically-named '$key.corrupt' siblings, and Journal adds two more (cadence-journal-beat, cadence-journal-clean). The count is stale from an earlier design; the top-of-file comment at store.dart:1-2 repeats it ("the native equivalent of the webapp's 4 localStorage keys").
  • Evidence: recorded run proof/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 …
  • Why it matters for a restaurant kitchen: indirectly — S13 must declare what the app stores on device for the App Store and Play data-safety forms, and the README is the document a reader reaches for first. An undercount by more than half is a bad starting point for a store declaration.
  • Proposed fix: 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.
  • How to prove the fix: 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.

S2-F20 — models.dart points readers at a symbol that does not exist

  • Severity: LOW
  • Location: lib/engine/models.dart:13 (valid at 03a176e)
  • What is wrong: the header comment directs the reader to "the one-time migration in Store … (see _kZoneSoundMigration)". No such identifier exists. The constant is _kZoneSound (store.dart:19) and the method is migrateZoneSounds (store.dart:255). Since legacyZoneId is the field at the centre of S2-F2 and S2-F4, and the comment is the only pointer explaining why a non-serialised field exists at all, the broken reference costs the next reader the exact context they need.
  • Evidence: $ grep -rn "_kZoneSoundMigration" lib/ lib/engine/models.dart:13: // read it (see _kZoneSoundMigration) — nothing else may use it. Single hit; the symbol is never declared.
  • Why it matters for a restaurant kitchen: indirectly — it is the comment guarding the field whose mishandling produces the BLOCKER at S2-F2.
  • Proposed fix: 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 the fix: grep -c "_kZoneSoundMigration" lib/ returns 1 today and 0 after.

S2-C1 — Concurrency: checked, no defect found

  • Severity: n/a (checked-clear)
  • Location: lib/engine/store.dart:133-154, lib/journal.dart:163-180 (valid at 03a176e)
  • What was checked: whether the 150 ms tick loop (ui/home.dart:154) can race the UI on a write. It cannot. Dart is single-isolate here; saveDefs/saveRun/saveClones do their jsonEncode synchronously and hand a complete string to prefs.setString before yielding, so no half-serialised state can be observed. The three keys are disjoint, so persistRun from the tick loop and persistDefs from an editor save never touch the same key. Back-to-back writes to the same key resolve in call order and the last writer wins, with no lost update. Journal serialises all file writes on the _chain future (journal.dart:33, 165), so concurrent log() and flushNow() calls cannot interleave a partial line.
  • Evidence: recorded run proof/01_findings/S2/04_write_concurrency.txt: W2 — after 3 back-to-back saveDefs: [{"id":"c","name":"C","durationSec":30,"sound":"Chirp","phrase":""}] The third write wins, as the call order requires.
  • One asymmetry worth recording, not a defect: Diag.clearCritical('save-$key') at store.dart:139 fires on any successful write to that key, so a success clears the banner raised by an earlier failure on the same key. That is the intended semantics (diagnostics.dart:40"A capability recovered") and the scope is per-key, so no unrelated banner is cleared. Proof: proof/01_findings/S2/04_write_concurrency.txt, test W3.

S2-C2 — Cross-reference for S1: an unknown or absent run status is silently coerced to running

  • Severity: MEDIUM (owned by the stream that holds models.dart; reported here because it is reached only through Store.load)
  • Location: lib/engine/models.dart:130-131, reached from lib/engine/store.dart:73 (valid at 03a176e)
  • What is wrong: RunEntry.fromJson resolves the status with RunStatus.values.firstWhere((s) => s.name == j['status'], orElse: () => RunStatus.running). A corrupt, missing, or wrong-typed status therefore produces a running entry rather than being dropped by the entry-level salvage. A dish the cook had paused silently resumes; a dish that was ringing silently reverts to counting down. Unlike every other corruption in the matrix, this one is not counted as dropped, so _preserveCorrupt is never called, no backup is taken, and no banner is raised.
  • Evidence: recorded run proof/01_findings/S2/01_corruption_matrix.txt: ROW | B6 unknown status string -> SILENTLY COERCED to running | threw=false | runsRecovered=3/3 | timersStillOk=3/3 | corruptKeyWritten=false | criticalBanner=false Contrast row B4, where a structurally invalid entry is dropped and preserved.
  • Why it matters for a restaurant kitchen: the failure direction is wrong. Engine.reconcile (engine.dart:74) drops a running entry with no endsAt, so the common case is caught — but an entry carrying a stale endsAt and a mangled status comes back as a live countdown against a deadline nobody set.
  • Proposed fix: make an unrecognised status a parse failure so the existing salvage handles it: dart status: RunStatus.values.firstWhere((s) => s.name == j['status']), // no orElse The StateError is caught by store.dart:74, the entry is dropped, and _preserveCorrupt runs.
  • How to prove the fix: s2_corruption_matrix_test.dart, row B6. Change expectRecovered: 3 to expectRecovered: 2. Red now, green after.

Coverage manifest

Every file in scope, read in full at commit 03a176e. Coverage figures are from flutter test --coverage on the unmodified copy (123 tests), reproducing the pinned baseline.

File Lines Coverage What was checked Findings
lib/engine/store.dart 354 94.40% (118/125) All 12 catch sites individually (37, 46, 55, 74, 82, 100, 109, 125, 141, 144, 177, 269) for what is caught, whether it is swallowed, whether the user or journal is informed, and whether the app can continue silently wrong. All 9 key constants plus the dynamic .corrupt siblings, for schema versioning, collisions and downgrade. Both migrations (migrateZoneSounds, repairGeneratedPhrases) for run-exactly-once, interruption at each of the three write points, idempotence across a reload, and skipped-version upgrades. seedIfFresh guards. Every write path (_write, _guard, saveDefs, saveRun, saveClones, lang=, vol=) for return-value checking and user notification. 21-row corruption matrix over the three data keys. Concurrency between the tick loop and the UI. F1, F2, F3, F4, F9, F10, F11, F12, F13, F14, F19, C1
lib/journal.dart 250 81.73% (85/104) All 6 catch sites (108, 128, 175, 200, 231, 233). Growth measured from the real call-site line shapes and the arithmetic recorded. Rotation trigger, rotation failure mode, and the _maxBytes/_keepBytes bound. Buffer handling across a failed write. init failure path and its visibility. Both prefs keys. Death-detection stamping. exportCopy failure reporting. Version/build reporting in the session header. Write serialisation via _chain. F5, F6, F7, F15, F16, C1
lib/diagnostics.dart 54 86.36% (19/22) No dedicated test file existed; one is now provided (s2_journal_diag_test.dart, 12 tests). Established what the file does, that Diag.fail can itself throw and that the throw escapes its caller's catch block, that the ring buffer bound holds at 50, that _warned and critical are bounded because the 35 Diag.fail call sites across lib/ use only 26 distinct scope literals, every one a compile-time constant or an interpolation over a constant key (grepped and listed), that Diag.log/DiagEntry.toString have zero production consumers, that error strings are copied unbounded into the journal, and that load- scopes have no clearCritical counterpart anywhere in lib/ while save- scopes do. F8, F17, F18
lib/engine/models.dart 160 — (not in scope) Read only where Store.load depends on it: TimerDef.fromJson (legacyZoneId at 78, absent from toJson at 56-63) and RunEntry.fromJson (status coercion at 130-131). Reported as cross-reference. F2 (mechanism), F20, C2

Proof index — proof/01_findings/S2/: 01_corruption_matrix.txt, 02_migration.txt, 03_journal_diagnostics.txt, 04_write_concurrency.txt, 05_full_suite_with_probes.txt, 06_mutation_M1_existing_suite.txt, 07_mutation_M1_probe_goes_red.txt, 08_journal_growth.txt, 09_full_suite_final.txt, 10_analyze.txt, mutation_M1.patch, and the five probe sources s2_corruption_matrix_test.dart, s2_migration_test.dart, s2_journal_diag_test.dart, s2_write_concurrency_test.dart, s2_journal_growth_test.dart.

S2 REFUTER — persistence, journal, diagnosticsagent_reports/S2_refute.md · raw .md

S2 REFUTER — persistence, journal, diagnostics

Governing rule: R5. Scope refuted: findings/S2_persistence.md (20 findings + S2-C1, S2-C2), its 21-row blast-radius table, and the proof under proof/01_findings/S2/.

Subject repo read-only at 03a176e72ef0075eec86b8915cbe6e93042a3b9d; git status --porcelain on the app repository empty before and after this work. All experiments on a fresh clone at a scratch working copy checked out at the same sha. My probe is written from scratch — I did not reuse S2's probe code — and is stored at proof/01_findings/S2_refute/s2r_refute_test.dart (19 tests, all green).

My recorded runs, all stamped with TREE_STATE, in proof/01_findings/S2_refute/:

File What it records
00_baseline_suite.txt pristine copy, flutter test → 123 passed, TREE_STATE: CLEAN
01_mutation_M1_baseline_suite.txt S2-M1 mutation applied, flutter test → 123 passed, EXIT_CODE=0, TREE_STATE: DIRTY (1 path)
02_refute_probe.txt my 19-test independent probe
03_rerun_S2_probes.txt S2's five probe files re-run on my clean copy → 38 passed
04_baseline_coverage.txt pristine flutter test --coverage → 123 passed
05_full_suite_with_all_probes.txt 180 passed with S2's probes + mine
06_analyze.txt / 07_analyze_without_refuter_probe.txt analyze with / without my probe
mutation_M1_refute.patch my independent mutation patch

0. Proof-integrity defect across ALL of S2's proof files

Every one of S2's ten .txt proof files lacks a TREE_STATE header, and every one stamps REPO: the app repository while CWD: a scratch working copy.

01_corruption_matrix.txt               TREE_STATE:absent  REPO:the app repository
02_migration.txt                       TREE_STATE:absent  REPO:the app repository
03_journal_diagnostics.txt             TREE_STATE:absent  REPO:the app repository
04_write_concurrency.txt               TREE_STATE:absent  REPO:the app repository
05_full_suite_with_probes.txt          TREE_STATE:absent  REPO:the app repository
06_mutation_M1_existing_suite.txt      TREE_STATE:absent  REPO:the app repository
07_mutation_M1_probe_goes_red.txt      TREE_STATE:absent  REPO:the app repository
08_journal_growth.txt                  TREE_STATE:absent  REPO:the app repository
09_full_suite_final.txt                TREE_STATE:absent  REPO:the app repository
10_analyze.txt                         TREE_STATE:absent  REPO:the app repository

run_and_record.sh carries a comment describing exactly this hole: "Pinning REPO to the original made every mutated-copy run report TREE_STATE: CLEAN, which is precisely the false assurance the tree-state stamp exists to prevent." S2's proofs predate that patch, so they cannot certify themselves either way. This bites hardest on 06_mutation_M1_existing_suite.txt and 07_mutation_M1_probe_goes_red.txt, which were necessarily recorded against a mutated tree that the header does not disclose, and on R8(d), which requires git status --porcelain empty after revert — S2 never recorded it.

This is a records defect, not a falsity finding. I re-ran all five S2 probe files on my clean copy (03_rerun_S2_probes.txt, 38/38 pass) and independently reproduced S2-M1 (§4). Every S2 proof I opened reproduces. The proofs are correct; they were simply not certifiable as recorded, and Phase 4 should re-record them rather than cite them.


1. Verdict per finding

CONFIRMED = the mechanism is real, the file:line is valid at 03a176e, the quoted code says what is claimed, and the proof artifact does what the report says when re-run.

# Claim (abridged) Verdict Severity Reasoning
S2-F1 A wrong-TYPE stored value loses ALL timers with no .corrupt backup and no banner CONFIRMED mechanism / REFUTED severity BLOCKER → MEDIUM Code and behaviour verified independently (my rows A10/A12/B7/C2 and test R1a: timers=0, backup=null, banner={}, then saveDefs writes []). But the trigger is not production-reachable — see §2.
S2-F2 The zone→sound migration is not idempotent; a second run rewrites every dish to Bell CONFIRMED and STRENGTHENED BLOCKER holds Reproduced twice from scratch, once with no write failure at all (§3). One wording correction below.
S2-F3 A wrong-TYPE cadence-zones-v1 marks the migration done without running it, then deletes the legacy tones CONFIRMED mechanism HIGH → MEDIUM store.dart:256-285 and the invariant at 260-261 verified; S2's probe DEFECT 2 reproduces (flag=true, zonesKeyStillThere=false). Trigger is the same unreachable wrong-type class as F1, so the severity must move with it.
S2-F4 A post-v0.4.11 timer is forced to Bell if the migration re-runs CONFIRMED HIGH agreed store.dart:273-279 verified: the loop has no legacyZoneId == null guard. Unlike F3 this is reachable through the F2 kill window, which needs no wrong type.
S2-F5 A failed journal write discards the buffered lines permanently and tells nobody CONFIRMED HIGH agreed journal.dart:167-168 clears _buf before the write; 175-177 is debugPrint only. Probe J1 reproduces: the two lines logged during the outage are absent, Diag.log=0, Journal.ready=true.
S2-F6 Journal rotation erases the whole journal when the file cannot be decoded CONFIRMED HIGH agreed journal.dart:192-203 verbatim as quoted; catch (_) { await f.writeAsString(''); }. Probe J3 reproduces: 3,146,812 bytes → 167 bytes. lcov confirms 192,194,195,196,197,199,201 are all DA:…,0 (§ my 04_baseline_coverage.txt).
S2-F7 A dead journal is completely silent CONFIRMED HIGH agreed journal.dart:108-111, 133-134, 208, 233 all verified. Probe J2/J7 reproduce.
S2-F8 Diag.fail can itself throw, and the throw escapes the caller's catch CONFIRMED HIGH agreed Independently reproduced (my 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) are valid.
S2-F9 Corrupt timers + wrong-typed seeded flag installs the demo kitchen over a real one CONFIRMED and STRENGTHENED HIGH agreed Reproduced (R9a). I additionally proved a variant needing no wrong type at all (R9b): a 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. F9 therefore stands on its own reachable trigger and does not fall with F1.
S2-F10 repairGeneratedPhrases deletes the operator's own sentence if it re-runs CONFIRMED MEDIUM agreed store.dart:219-231, flag at 229, doc comment at 215-216 all verified. Wording correction below.
S2-F11 A zone whose sound has the wrong type is skipped in silence CONFIRMED MEDIUM agreed store.dart:267 is if (id is String && s is String) tones[id] = s; with no else. Probe DEFECT 3 reproduces (diagScopes=[]).
S2-F12 The .corrupt preservation is write-only CONFIRMED MEDIUM agreed My own whole-tree grep returns the same three hits, all prose in comments (i18n.dart:141, engine/engine.dart:70, audio/alarm_volume.dart:42). The only .corrupt reader is store.dart:122. See missed finding M4, which makes it worse.
S2-F13 Every uncovered line in store.dart is a failure handler; five are write-failure paths CONFIRMED MEDIUM agreed lcov reproduced exactly on my 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%. I executed line 137 for the first time (test R13a, a genuinely refusing platform store) and the branch behaves as designed: critical={save-cadence-timers-v1}.
S2-F14 Deleting the migration's persistence step passes all 123 tests (mutation S2-M1) CONFIRMED — reproduced independently MEDIUM agreed See §4.
S2-F15 The journal header cannot distinguish two builds of one version CONFIRMED MEDIUM agreed journal.dart:83 prints Cadence v$appVersion; main.dart:20 is const String kAppVersion = '0.4.12';; pubspec.yaml:5 is 0.4.12+18; version_test.dart compares .split('+').first and its comment states the omission verbatim.
S2-F16 Rotation is checked only at init; ~34 days to the 3 MiB cap CONFIRMED verdict / REFUTED figures MEDIUM agreed The cap exists and the direction of the grading is right; the digits are under-measured. See §5.
S2-F17 Diag.log and DiagEntry.toString are dead in production CONFIRMED MEDIUM agreed My own 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 the buffer and the journal CONFIRMED MEDIUM agreed Independently reproduced (R18a): in-RAM entry length 200000.
S2-F19 README.md claims 4 prefs keys; Store writes 10 in a boot CONFIRMED LOW agreed grep -c "static const _k" lib/engine/store.dart = 9; README.md:14 says (4 clés, écriture immédiate); store.dart:1 repeats it.
S2-F20 models.dart points readers at a symbol that does not exist CONFIRMED LOW agreed grep -rn "_kZoneSoundMigration" lib/ → single hit, lib/engine/models.dart:13, never declared.
S2-C1 Concurrency checked, no defect found CONFIRMED n/a Probe W2/W3 reproduce; single-isolate reasoning and the disjoint-key argument hold for the three data keys. Does not cover the clones→run coupling in M1, which is a load-ordering issue, not a concurrency one.
S2-C2 An unknown or absent run status is silently coerced to running CONFIRMED with a correction MEDIUM agreed The coercion is real (models.dart:131-132). Two corrections: the cited range models.dart:130-131 excludes the orElse: clause, which is on line 132; and the outcome is not always "not dropped" — see row B6 in §6.

Counts: 22 items assessed (20 findings + 2 closing sections). 22 mechanisms CONFIRMED. 2 severities changed (F1 BLOCKER→MEDIUM, F3 HIGH→MEDIUM). 6 sub-claims REFUTED inside otherwise-confirmed findings (F1's severity; F16's figures; blast-radius row B6; the "cross-key blast radius is zero" structural conclusion; the "_guard never verifies" wording in F2 and F10; the models.dart:130-131 range in C2).

Wording correction that appears in two findings

S2-F2 and S2-F10 both state that _guard "never verifies" the write. That is not what the code does:

// store.dart:174-180
void _guard(String key, Future<bool> write) {
  write.then((ok) {
    if (!ok) Diag.fail('save-set-$key', 'write returned false');
  }).catchError((e) {
    Diag.fail('save-set-$key', e);
  });
}

_guard does observe the result and does report it. What it does not do is (a) raise it as critical, so no operator banner lights, (b) retry, or (c) let any caller await it. I proved the observed behaviour with a genuinely refusing platform store (test R2a): a failed _kZoneSound write produces Diag scopes=[save-set-cadence-zone-sound-v1, save-set-cadence-zones-v1] with critical={}. The finding survives; the sentence must be corrected before it reaches a report, because "never verifies" is falsifiable in one grep.


2. BLOCKER 1 — S2-F1, the wrong-TYPE stored value

The mechanism is real and I reproduced it independently. Test R1a, my own code:

R1a CONFIRMED — timers=0, backup=null, banner={}, after saveDefs the key is "[]"

_readString (store.dart:34-41) catches the TypeError, reports it non-critically, returns null; _readList (store.dart:93) cannot distinguish that null from "key absent" and returns [] before _preserveCorrupt is reached. The asymmetry against every JSON-level corruption class is exactly as S2 describes.

The trigger is not reachable in production. I attacked this on five fronts:

  1. The app's own writers, across its whole history. I read lib/engine/store.dart at every revision that touched it (22902e0, f47f2e5, 7f3870c, fab7c0a, 8461639, 07ee62a, 03a176e). Every key has had exactly one typed setter for its entire life: cadence-timers-v1 / cadence-run-v1 / cadence-clones-v1 / cadence-zones-v1 / cadence-lang / *.corruptsetString; cadence-seeded-v1 / cadence-phrase-repair-v1 / cadence-zone-sound-v1setBool; cadence-volsetDouble. The Journal keys (cadence-journal-beat int, cadence-journal-clean bool) are disjoint names. A downgrade to any shipped build cannot produce a wrong type, because no shipped build ever wrote one. Asserted for the pinned commit in test R1b: R1b KEY TYPES AFTER A FULL BOOT: {cadence-lang: String, cadence-timers-v1: String, cadence-run-v1: String, cadence-clones-v1: String, cadence-seeded-v1: bool, cadence-vol: double, cadence-phrase-repair-v1: bool, cadence-zone-sound-v1: bool}
  2. The native layer. grep -rn "SharedPreferences|getSharedPreferences|UserDefaults" android ios returns only ios/Runner/GeneratedPluginRegistrant.m (plugin registration). MainActivity.kt never touches preferences. The one cadence- literal outside store.dart/journal.dart is lib/alarm_backstop.dart:46, a notification channel id, not a prefs key.
  3. The plugin's own encoding, Android. shared_preferences_android-2.4.27 LegacySharedPreferencesPlugin.kt transforms a stored value's type only when a String begins with LIST_PREFIX / BIG_INTEGER_PREFIX / DOUBLE_PREFIX (base64 magic strings), or when the value is a Set left by a long-retired setStringSet. This app's JSON payloads begin with [ or {, setStringSet is never called, and setString throws at write time on a magic-prefix collision rather than storing one. No route.
  4. The plugin's own encoding, iOS. shared_preferences_foundation-2.5.6 reads UserDefaults.standard back untransformed. No route.
  5. OS backup/restore. Android auto-backup and iOS backups restore the prefs file verbatim; types are preserved by construction, so a restore cannot manufacture one.

The read path is faithful to production, which is the one thing that supports the finding: SharedPreferences.getString is _preferenceCache[key] as String? (shared_preferences-2.5.5/lib/src/shared_preferences_legacy.dart:129), the same cast for setMockInitialValues and for a real device. So the probe is honest — it is the state it seeds that cannot occur.

Verdict: the BLOCKER does not survive. Producing this state requires an agent outside the app mutating the preferences store: a prefs editor on a rooted device, run-as against a debuggable build, or a hand edit. Under R13, a severity has to be evidence-based, and there is no evidence of a production trigger. BLOCKER → MEDIUM: a real robustness asymmetry in a path the author deliberately defended (store.dart:32"a wrong-TYPE value must never crash the boot" — and robustness_test.dart:79), inconsistently handled, worth the five-line fix S2 proposes, but not a data-loss BLOCKER.

The exact artifact that would flip this back to BLOCKER: any writer — a shipped build, a plugin version, a platform component, an MDM/managed-configuration path, or a documented backup/restore behaviour — that stores a non-String under one of cadence-timers-v1, cadence-run-v1, cadence-clones-v1, cadence-zones-v1, cadence-lang, or a non-bool under one of the three one-shot flags. The test that settles it: grep for prefs.set* against those key names across every reachable writer, which I ran across the full git history and both platform plugins and which returned exactly one type per key.

Consequence for the two findings that depend on the same trigger: S2-F3 moves HIGH → MEDIUM for the same reason. S2-F9 does not move, because I proved a wrong-type-free path to it (R9b).


3. BLOCKER 2 — S2-F2, the non-idempotent migration

(The coordinator's brief called this "S2-F5"; in findings/S2_persistence.md the migration idempotency finding is S2-F2 and S2-F5 is the journal-write discard. I audited both.)

CONFIRMED, and the reachability chain is stronger than S2 argued. The mechanism is exactly as described: TimerDef.toJson (models.dart:56-63) omits zoneId, so after the migration's own saveDefs(e) at store.dart:280 every legacyZoneId is null on the next load, and line 274 evaluates tones[null] ?? 'Bell' for every timer.

I established reachability twice, from scratch, without hand-building a prefs map:

R2b — a genuinely refused flag write, then a reboot. A platform store that refuses writes to cadence-zone-sound-v1 and cadence-zones-v1 (what a full data partition does), boot 1 runs the migration, then a second Store is opened over whatever actually reached the disk:

R2b boot1={Fries: Beep, Crispy: Beep, Dough: Chime}
R2b boot2 moved=3 boot2={Fries: Bell, Crispy: Bell, Dough: Bell}
R2b persisted after boot2: [{"id":"a","name":"Fries",…,"sound":"Bell",…},
                            {"id":"b","name":"Crispy",…,"sound":"Bell",…},
                            {"id":"c","name":"Dough",…,"sound":"Bell",…}]

R2c — no write failure anywhere. The order at store.dart:280-283 is saveDefs → flag → remove zones. A process destroyed after the first write lands and before the other two do leaves the zones key present and the flag unset. That is precisely the window the doc comment declares safe:

"Idempotent by construction: it re-reads the same zones and assigns the same tones, so dying before the flag is written costs nothing." (store.dart:249-252, verbatim)

R2c after a kill in the documented-safe window: moved=3 boot2={Fries: Bell, Crispy: Bell, Dough: Bell}

So the second run needs no storage fault at all — only a process death in a window the code's own comment names and blesses. On a kiosk tablet that upgrades and is power-cycled by a kitchen, that is an ordinary event, and the migration runs exactly once per tablet so there is no second chance.

One gap in S2's chain that I closed rather than exploited. S2 argues reachability from the lost flag alone. A damaging second run also requires cadence-zones-v1 to have survived, and line 283 removes it. Both facts point the same way: the removal is ordered after the flag write, so any interruption or refusal that loses the flag also tends to keep the zones key. R2b and R2c both demonstrate the joint state directly, so the chain is complete either way.

Verdict: the BLOCKER survives, at BLOCKER. Under R13 this corrupts a user's stored data: the board is rewritten and persisted (R2b persisted after boot2, above), the operator's per-station tones are gone with no .corrupt backup and no banner, and the alarm still rings so nothing looks broken. S2's proposed fix (if (zid == null) continue;) is correct, stays inside R6, and resolves S2-F4 as claimed.


4. Mutation S2-M1 — reproduced

I wrote my own patch against store.dart:280 and ran the unmodified 123-test baseline (S2's five probe files were not present in the tree):

--- store.dart.orig
+++ lib/engine/store.dart
@@ -277,7 +277,7 @@
-      if (moved > 0) saveDefs(e);
+      // MUTATION S2-M1 (refuter replication): migration no longer persists the inherited tones

proof/01_findings/S2_refute/01_mutation_M1_baseline_suite.txt:

TREE_STATE: DIRTY (1 path(s) modified)
TREE_DIFF:
   M lib/engine/store.dart
…
00:01 +123: All tests passed!
EXIT_CODE=0

Reverted with git status --porcelain empty afterwards, satisfying R8(d), which S2 did not record.

S2-M1 is real: the migration can be made to persist nothing and the whole suite stays green. The line deleted is the one store.dart:251-252 names as the safety guarantee. S2-F14 stands.


5. Journal growth — verdict confirmed, figures refuted

The cap exists (journal.dart:25, _maxBytes = 3 * 1024 * 1024 = 3,145,728 bytes) and is checked only at init (journal.dart:72). S2's refusal of the "unbounded growth" hypothesis it was primed with is correct and I confirm it plainly: the file is bounded across restarts and grows without bound only inside one continuous session. That is a MEDIUM, exactly as graded.

S2's arithmetic is internally correct but its inputs are under-measured. s2_journal_growth_test.dart:37-38 claims "Line shapes copied verbatim from lib/ui/home.dart:358, 289, 307, 368". They are not:

Line S2's probe The actual call site
alarm sonne — decalage 12 ms (prevu 18:42:03.000) home.dart:289-292 also appends · app a l'ecran · sonnerie=Beep
stop coupee apres 6 s home.dart:368-371 emits alarme coupee apres 3.4 s
heartbeat snapshot timers=7 actifs=3 (17 chars) home.dart:100-102 emits timers=7 actifs=2 sonnent=0 lots=1 ecran (40 chars)
spoken lines omitted voice.dart:165 writes a parole line per announcement — one per alarm and per repeat

Measured off the real journal file with the full call-site strings (test R16b), holding S2's own service assumptions (210 dish cycles/day, 768 written heartbeats/day):

R16b MEASURED header=167 B; full 10-line cycle=478 B; beat=67 B
R16b MEASURED per day = 151836 B = 148.3 KiB/day; days to the 3 MiB cap = 20.7

and, for a like-for-like comparison against S2's 6-line cycle shape (test R16a):

R16a BYTES per line: depart=33 alarme=87 rappel=43 parole=44 arret=53 beat=67
R16a [S2 6-line cycle] 210 cycles + 768 beats = 114876 B/day = 112.2 KiB/day; days to cap = 27.4
R16a quiet floor (beat only, 1 line / 5 min) = 131.9 KiB/week

S2's session header of 167 bytes reproduces exactly. Everything else does not:

Quantity S2 Re-measured
one heartbeat line 44 B 67 B
one 6-line dish cycle 279 B 302 B
realistic dish cycle (incl. the spoken lines) not measured 478 B
per day 92,382 B = 90.2 KiB 151,836 B = 148.3 KiB
one week 0.62 MiB 1.01 MiB
days to the 3 MiB cap 34.1 20.7
quiet floor 86.6 KiB/week 131.9 KiB/week

Every digit S2 published for this finding except the 167-byte header and the 3,145,728-byte cap must be replaced. The verdict does not move — 20.7 days is still weeks, still a kiosk-only exposure, still MEDIUM, and the interaction with S2-F6 that drives the severity is unchanged.

On the S12 discrepancy. S12's 27,585 bytes/hour is 662,040 B/day = 646.5 KiB/day, about 7.2× S2's 90.2 KiB/day. My evidence speaks to one of the two factors and not the other. Per-line byte size: S2 is under-measured, and correcting it alone moves 90.2 → 148.3 KiB/day, closing roughly 1.6× of the 7.2× gap. The residual ~4.4× lies entirely in the assumed event rate — S2 hand-assumed 210 dish cycles and 768 written heartbeats per day, and I deliberately held those assumptions fixed so the byte-size correction would be isolated. I measured no event rate of my own, so I cannot adjudicate that half. What I can say is directional and evidence-backed: the byte sizes favour S12, not S2, and a measured soak is a better input for the event rate than a hand-built assumption, so I would expect the reconciliation to land closer to S12 than to S2.


6. Blast-radius rows re-derived independently

Eleven rows, re-derived with my own probe over a baseline of 3 valid dishes and 3 run entries (and, for the C rows, 3 clones with the run entries a clone needs to survive reconcile). Raw: proof/01_findings/S2_refute/02_refute_probe.txt.

RROW | A0  control, clean JSON                            | timers=3/3 | runs=3/3 | backup=false | banner=false
RROW | A1  malformed JSON under the timers key            | timers=0/3 | runs=0/3 | backup=true  | banner=true
RROW | A5  one element is a bare int                      | timers=2/3 | runs=2/3 | backup=true  | banner=true
RROW | A7  one element missing id                         | timers=2/3 | runs=2/3 | backup=true  | banner=true
RROW | A10 WRONG PREFS TYPE: int under the timers key     | timers=0/3 | runs=0/3 | backup=false | banner=false
RROW | A12 WRONG PREFS TYPE: List<String> under timers    | timers=0/3 | runs=0/3 | backup=false | banner=false
RROW | B1  bad JSON under the run key                     | timers=3/3 | runs=0/3 | runBackup=true | timerBackup=false | banner={load-cadence-run-v1}
RROW | B7  WRONG PREFS TYPE: int under the run key        | timers=3/3 | runs=0/3 | backup=false | banner=false
RROW | C0  control, clean clones                          | timers=3/3 | runs=6/6 | clones=3/3   | banner=false
RROW | C1  malformed JSON under the clones key            | timers=3/3 | runs=3/6 | clones=0/3   | backup=true  | banner=true
RROW | C2  WRONG PREFS TYPE: int under the clones key     | timers=3/3 | runs=3/6 | clones=0/3   | backup=false | banner=false
RROW | D1  every settings key wrong-typed                 | timers=3/3 | lang=en  | vol=1.0      | banner=false

Ten of the eleven reproduce S2's table exactly. The salvage design goal (one bad dish costs one dish) is met and independently confirmed; so is the total-loss-with-no-backup-no-banner column for every wrong-type row.

Row I could not reproduce: B6

S2's row B6 reads "unknown status string → none dropped — silently coerced to running". That is true only when the mangled entry carries an endsAt. Test B6a/B6b:

RROW | B6a unknown status on a RUNNING entry (has endsAt) | runs=3/3 | status of "a"=running | backup=false | banner=false
RROW | B6b unknown status on a PAUSED entry (no endsAt)   | runs=2/3 | "b" present=false     | backup=false | banner=false

A paused dish stores remainingMs and no endsAt. Coerced to running by models.dart:131-132, it then fails engine.dart:82if (r.status == RunStatus.running && r.endsAt == null) return true; — and is removed by reconcile(). The record is lost, still with no .corrupt backup and no banner. The cell should read: "silently coerced to running; the entry survives if it carried an endsAt, and is silently dropped by reconcile if it was paused. No backup, no banner, either way."

For a kitchen that is the worse half: the dish a cook deliberately paused is the one that disappears off the board. Phase 2 must not carry "none dropped" into a data-safety declaration.

Structural conclusion I must refute: cross-key blast radius is not zero

S2 concludes: "Corruption of cadence-timers-v1 never damages cadence-run-v1 or cadence-clones-v1 and vice versa … Cross-key blast radius is zero in every row tested."

The first half is right for the timers key. The second half is false, and rows C1/C2 above show it: corrupting cadence-clones-v1 alone takes three run entries with it (runs=6/6runs=3/6), because Engine.reconcile (engine.dart:76-78) drops any clone without a live parent and then engine.dart:80 drops every run entry whose id is no longer a def or a clone. The run key was never corrupt and gets no .corrupt backup of its own. Filed below as M1.


7. Findings S2 missed (R5 deliverable)

Each is held to R2: file:line valid at 03a176e, verbatim proof, reproduced in proof/01_findings/S2_refute/02_refute_probe.txt.

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

  • Severity: HIGH
  • Location: lib/engine/store.dart:65 and lib/engine/engine.dart:76-80 (valid at 03a176e)
  • What is wrong: load decodes the three keys independently, but reconcile() then couples them. clones = clones.where((c) => ids.contains(c.parentId) && run.containsKey(c.id)) followed by run.removeWhere((id, _) => !valid.contains(id)) means that losing the clones list also deletes every run entry belonging to a batch. A corrupt cadence-clones-v1 — reachable through the ordinary JSON path (row C1), no wrong type needed — therefore destroys live countdowns stored under a key that was never damaged, and takes no .corrupt backup of the run key. The next saveRun makes it permanent.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: batches are how a kitchen runs three orders of fries at once. A corrupt clones value makes three live countdowns disappear from the board mid-service. The banner that lights says load-cadence-clones-v1 — nothing tells the operator that running timers were lost with it.
  • Proposed fix: 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 the fix: 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.

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

  • Severity: MEDIUM
  • Location: lib/journal.dart:186-190, called from lib/ui/home.dart:202 (valid at 03a176e)
  • What is wrong: the death detector's whole value is that !clean && lastBeat != null (journal.dart:85) means the OS killed us. markCleanExit buffers a line, then awaits a flush: true file write, and only then writes cadence-journal-clean = true. home.dart:202 calls it without awaiting, from the AppLifecycleState.detached callback — the last moment before the process is destroyed. Any death inside that window leaves the marker false, and the next boot prints !! SESSION PRECEDENTE TUEE.
  • Evidence: M2 cadence-journal-clean on disk when the callback returned = false M2 next boot reports "SESSION PRECEDENTE TUEE" = true
  • Why it matters for a restaurant kitchen: this is the same failure class as S2-F15 and the incident in test/version_test.dart:1-8 — a flight recorder that misreports sends the next investigation into the wrong code. A false kill report invites Serge to hunt a manufacturer battery killer that is not there.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/diagnostics.dart:41-46 and its six call sites (valid at 03a176e)
  • What is wrong: Diag.clearCritical is called for backstop-init, backstop-schedule, backstop-exact, voice-init, audio-play and save-$key — never for a load- scope. Once _preserveCorrupt raises load-cadence-timers-v1 (store.dart:120), the operator banner stays lit for the whole session no matter what the operator does, because Store.load runs once at home.dart:75 and nothing else can retract the scope. S2's own probe measured this (test D5) and its coverage manifest notes it; no finding was filed.
  • Evidence: 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}
  • Why it matters for a restaurant kitchen: the banner exists so a kitchen KNOWS an alarm capability is degraded. One that cannot be dismissed after the operator has rebuilt the board teaches the kitchen to ignore banners, which disarms every other one — including the audio and backstop banners that mean the alarm will not ring.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/engine/store.dart:123 (valid at 03a176e)
  • What is wrong: the comment at store.dart:115-118 states "Corrupt stored data is never silently lost". The write that delivers that guarantee is _guard('$key.corrupt', prefs.setString('$key.corrupt', raw)) — the settings guard, documented one screen away as "comfort data — failure is logged, not critical" (store.dart:173). If that write is refused, the only copy of the kitchen's configuration is gone and the failure is reported at the same non-critical level as a language preference. S2-F12 shows the backup is unreadable; this shows it may not exist at all.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the whole preservation mechanism is the fallback for a corrupt board. On the tablet where it matters — one whose storage is already misbehaving — it is the most likely write to fail, and it fails quietly.
  • Proposed fix: 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 the fix: 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.

8. Coverage manifest — what I checked in each file

File Lines What I checked, beyond re-reading S2's citations
lib/engine/store.dart 354 Read in full. Verified all 22 file:line citations S2 makes against this file. Traced every one of the nine key constants through all six historical revisions of the file (22902e003a176e) to settle F1's reachability. Verified _guard (174-180) and _write (133-147) semantics by execution against a genuinely refusing platform store, exercising line 137 for the first time in the project. Re-derived the load path (63-113) against 11 corruption classes. Re-derived both migrations (219-231, 255-285) including two independent reachability constructions for the second run. Re-derived seedIfFresh (300-353) including a wrong-type-free path. Confirmed the .corrupt write at 123 uses the non-critical guard (M4). Counted static const _k = 9 for F19.
lib/journal.dart 250 Read in full. Verified all 12 file:line citations. Re-measured the growth arithmetic off the real journal file with the verbatim call-site format strings from home.dart:289/307/358/368 and voice.dart:165, in UTF-8 bytes (the format strings contain · and , 2 and 3 bytes). Verified _maxBytes/_keepBytes (25-26) and that the size check at 72 is the only one. Verified the init catch (108-111) leaves _file = null with only a debugPrint. Verified _flush clears _buf before the write (167-168). Verified _rotate (192-203) truncates to empty on any failure. Audited markCleanExit (186-190) and its single caller home.dart:202, which S2 listed as checked and filed nothing on → M2. Confirmed _chain serialisation (33, 165) holds. Confirmed the two prefs keys are disjoint from every Store key.
lib/diagnostics.dart 54 Read in full — all 54 lines. Verified all 5 citations. Independently reproduced the hostile-toString escape (F8) and the unbounded-string copy (F18) with my own code. Verified DiagEntry has zero references outside diagnostics.dart and Diag.log zero readers in lib/ (F17), and reproduced the lcov uncovered set [15,16,20]. Enumerated all six clearCritical call sites and established that no load- scope has one → M3. Verified the ring-buffer bound at 50 and that _warned/critical are bounded by compile-time scope literals.
lib/engine/models.dart 160 Read in full (cross-reference only, as S2 scoped it). Verified toJson omits zoneId (56-63) — the mechanism of F2. Verified legacyZoneId is read at 78. Corrected S2-C2's citation: the orElse: () => RunStatus.running is on line 132, outside the cited 130-131. Traced the coercion through Engine.reconcile and found the paused-entry drop that refutes blast-radius row B6. Verified _kZoneSoundMigration at line 13 is undeclared (F20).
lib/engine/engine.dart (out of S2 scope) Read reconcile() (74-98) only, because it is where Store.load's output is filtered: it supplies the mechanism for the B6 correction (line 82) and for M1 (lines 76-80).
README.md Verified line 14 verbatim for F13 and F19.
test/robustness_test.dart, test/store_test.dart, test/journal_test.dart, test/version_test.dart Verified every test citation S2 makes (robustness_test.dart:79; store_test.dart:104, 145-158, 172; journal_test.dart:45; version_test.dart:1-8 and its .split('+').first comparison). Confirmed robustness_test.dart:79 asserts expect(e.timers, isEmpty) — it does encode the data loss as the expected outcome, as F1 says.
S2's five probe files Copied into my clean copy and re-run: 38/38 pass (03_rerun_S2_probes.txt). Read s2_journal_growth_test.dart:37-45 line by line against the real call sites, which is where the growth under-measurement came from.

Baseline reproduced on my copy at every step: flutter test 123 passed; flutter test --coverage total 858/1927 = 44.53%; flutter analyze --fatal-infos --fatal-warningsNo issues found with S2's probes present (07_analyze_without_refuter_probe.txt). My own probe adds three info diagnostics (06_analyze.txt) because it imports shared_preferences_platform_interface transitively; a Phase-4 agent adopting it must add that dev dependency. No shipped code is implicated.


9. Summary

  • 22 items assessed. 22 mechanisms CONFIRMED. 2 severities changed. 6 sub-claims REFUTED.
  • BLOCKER 1 (S2-F1, wrong-TYPE value) does NOT survive → MEDIUM. The asymmetry is real and I reproduced it, but the state cannot be produced by any shipped build, the native layer, either platform plugin, or an OS backup restore. It requires a store mutated from outside the app.
  • BLOCKER 2 (S2-F2, non-idempotent migration) SURVIVES at BLOCKER, and its reachability is stronger than S2 argued: a process death in the window store.dart:249-252 explicitly declares safe is sufficient, with no storage fault anywhere.
  • S2-M1 reproduced independently: deleting store.dart:280 leaves all 123 baseline tests green, EXIT_CODE=0, tree restored clean afterwards.
  • Journal growth: verdict confirmed, digits refuted. 148.3 KiB/day and 20.7 days to the 3 MiB cap, not 90.2 KiB/day and 34.1 days. Only the 167-byte header and the 3,145,728-byte cap survive intact.
  • 4 missed findings contributed (1 HIGH, 3 MEDIUM), each with file:line, verbatim proof, a fix inside R6, and a red-now/green-after test.
  • All ten of S2's proof files lack TREE_STATE and stamp the pinned repo rather than the copy they ran against. The proofs reproduce; they are simply not self-certifying, and Phase 4 should re-record rather than cite them.

Stream S3: finding and refutation

S3 — Audio, voice, alarm backstop and native bridgesfindings/S3_audio_alarms.md · raw .md

S3 — Audio, voice, alarm backstop and native bridges

Subject: the app repository at pinned commit 03a176e72ef0075eec86b8915cbe6e93042a3b9d (v0.4.12+18). Every file:line below was opened at that commit and is valid there.

Work copy: a scratch working copy (rsync of the pinned tree, same sha — verified git rev-parse HEAD03a176e72ef0075eec86b8915cbe6e93042a3b9d). The subject repo was never written to.

Raw proof: proof/01_findings/S3/. Tests written by this stream: proof/01_findings/S3/tests/ (4 files, 40 tests). Mutation patches and their red runs: proof/01_findings/S3/mutations/. Official-doc captures: proof/01_findings/S3/captures/ (retrieved 2026-08-04 UTC via utilities/chrome.py).

Measured effect of this stream's tests (proof/01_findings/S3/03_test_coverage_with_s3.txt, 03_lcov_with_s3.info):

File Line coverage at 03a176e With S3 tests
lib/audio/audio.dart 5.26% (2/38) 97.37% (37/38)
lib/audio/voice.dart 86.36% (76/88) 96.59% (85/88)
lib/alarm_backstop.dart 78.00% (78/100) 94.00% (94/100)
lib/audio/alarm_volume.dart 100.00% (14/14) 100.00% (14/14)
lib/ui/home.dart 0.00% (0/390) 36.67% (143/390)
Whole project 44.53% (858/1927) 71.93% (1386/1927)
flutter test 123 passed 163 passed, 0 failed
flutter analyze 0 issues 0 issues

Section A — the eight catch (_: Exception) sites in MainActivity.kt

grep -n "catch (_: Exception)" android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt returns exactly 8 hits, at lines 58, 79, 87, 92, 97, 105, 151, 172 (grep -c8). grep -c "Log\." MainActivity.kt0: the file contains no logging of any kind, so nothing from these handlers reaches logcat either.

# Line Verbatim What can throw / what is lost there What the activity does next Who learns about the failure
1 57-58 try { audio.setStreamVolume(AudioManager.STREAM_ALARM, target, 0) }
catch (_: Exception) {}
SecurityException"if the volume change triggers a Do Not Disturb change and the caller is not granted notification policy access" (captures/android_audiomanager.txt:4663). Also any OEM or device-owner restriction on MODIFY_AUDIO_SETTINGS. result.success(null) (line 59) Nobody. .catchError at lib/ui/home.dart:211 cannot fire; no Diag, no journal, no banner. See S3-F3.
2 79 } catch (_: Exception) { emptyList<Map<String, Any>>() } tts?.voices (TextToSpeech.getVoices) on an engine that has not finished loading voice data, or any RuntimeException from a third-party engine; v.locale.toLanguageTag() on a malformed Locale. result.success(list) with an empty list (line 80) Nobody. VoiceBox._pickVoice (lib/audio/voice.dart:106-138) sees an empty list, leaves best == null, and writes the journal line voix: aucune voix adaptee — voix par defaut du moteur — byte-identical to the legitimate "no matching voice" case.
3 84-87 val ok = try { val v = tts?.voices?.firstOrNull { it.name == name }
if (v != null) { tts?.voice = v; true } else false }
catch (_: Exception) { false }
tts?.voices again, or the tts?.voice = v setter rejecting the voice. result.success(false) (line 88) Nobody. lib/audio/voice.dart:134 is await _ch.invokeMethod('setVoice', best); — the boolean is discarded, and line 135 then writes voix: choisie: $best to the journal. The flight recorder records the opposite of what happened.
4 91-92 try { tts?.setLanguage(Locale.forLanguageTag(call.arguments as String)) }
catch (_: Exception) {}
The as String cast (inside the try). More importantly setLanguage does not throw at all — it returns LANG_MISSING_DATA (-1) or LANG_NOT_SUPPORTED (-2) (captures/android_texttospeech.txt:1454-1462), and that return value is dropped on the floor. result.success(null) (line 93) Nobody. See S3-F4.
5 96-97 try { tts?.setSpeechRate((call.arguments as Number).toFloat()) }
catch (_: Exception) {}
The cast; setSpeechRate returns ERROR/SUCCESS, also discarded. result.success(null) (line 98) Nobody. Consequence is cosmetic: the engine keeps its default rate.
6 105 try { tts?.stop() } catch (_: Exception) {} A dead or rebound engine binder. completeAllSpeaks() then result.success(null) (lines 106-107) Nobody — but this is the best-behaved of the eight: completeAllSpeaks() runs unconditionally, so the Dart queue is always unblocked. Residual effect: the previous phrase keeps playing under the next one.
7 150-151 val r = try { t.speak(text, TextToSpeech.QUEUE_FLUSH, params, id) }
catch (_: Exception) { TextToSpeech.ERROR }
t.speak on a dead engine. pendingSpeaks.remove(id), result.success(false) (lines 152-155) Nobody. lib/audio/voice.dart:166-168 discards the boolean, and voice.dart:165 has already written parole "<text>" to the journal before the call. See S3-F5.
8 172 try { tts?.shutdown() } catch (_: Exception) {} An already-dead engine binder in onDestroy. tts = null, super.onDestroy() (lines 173-174) Nobody. Note it does not call completeAllSpeaks(), so an in-flight FlutterResult is dropped; harmless here because the Dart isolate dies with this single activity.

Answer to the project owner's question: 8 of 8 sites swallow the failure with nobody informed — not Dart (two of them return a false that Dart discards), not Diag, not the operator banner, not the on-device journal, not logcat.


Section B — findings

S3-F1 — the OS-level alarm backstop is dead on iOS, and the app boots into a permanent red banner

  • Severity: BLOCKER
  • Location: lib/alarm_backstop.dart:72-76 (valid at 03a176e)
  • What is wrong: Backstop.init() calls _plugin.initialize with an InitializationSettings that carries only an android: block. flutter_local_notifications 22.1.0 throws ArgumentError('iOS settings must be set when targeting iOS platform.') before it resolves any platform implementation. That throw is caught at alarm_backstop.dart:92-96, which sets _ready = false and calls Diag.fail('backstop-init', e, isCritical: true). Consequences on every iOS launch: (a) the operator banner (lib/ui/home.dart:670) is red from the first frame and never clears, (b) sync(), showNow(), onBackground() and onForeground() all return at their if (!_ready) return guards, so no notification is ever scheduled or shown on iOS, (c) iOS notification permission is never requested — requestNotificationsPermission() is resolved through AndroidFlutterLocalNotificationsPlugin only (alarm_backstop.dart:77-79), and (d) _details (alarm_backstop.dart:61-62) has no iOS: block either, so even a repaired init would schedule soundless notifications.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: on an iPad the only thing standing between a forgotten fryer and a fire is the in-app timer, and the app has no way to ring once iOS suspends it. The cook also sees a permanent error banner across the board from launch, which trains the brigade to ignore the banner — so the real failures it exists to report become invisible too. For the App Store goal it is worse than a missing feature: it is a shipped app that reports itself broken on first launch.
  • Proposed fix: 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.
  • How to prove the fix: 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.

S3-F2 — an iOS build cannot ring at all once it leaves the screen

  • Severity: BLOCKER
  • Location: ios/Runner/Info.plist (whole file, valid at 03a176e); lib/ui/home.dart:154
  • What is wrong: the alarm depends on a 150 ms Dart Timer.periodic (lib/ui/home.dart:154-167). iOS suspends an app's run loop shortly after it is backgrounded unless the app declares a background mode; Info.plist declares none — there is no UIBackgroundModes key anywhere in the file (grep -c UIBackgroundModes ios/Runner/Info.plist0). The designed compensation is the notification backstop, and S3-F1 proves that is inert on iOS. The two failures compose into: on iPad, backgrounding the app or locking the screen means the timer never rings.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: a cook checks a supplier message, the board goes to the background, and the pass loses every timer silently. This is the exact failure mode the Android backstop was built for (lib/alarm_backstop.dart:1-6), left unimplemented on the platform the App Store goal depends on.
  • Proposed fix: fixing S3-F1 is the fix — a scheduled UNCalendarNotificationTrigger fires while the app is suspended and does not require a background mode. Do not add UIBackgroundModes: audio as a substitute: it is not needed once notifications work, and App Review rejects it when no audio actually plays in the background.
  • How to prove the fix: device protocol D1 below. Static proof is not available for this one and is not claimed.

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

  • Severity: BLOCKER
  • Location: android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:57-59 (valid at 03a176e); Dart side lib/ui/home.dart:209-218
  • What is wrong: the single most safety-critical platform write in the product is wrapped in an empty catch and then answered with success: kotlin try { audio.setStreamVolume(AudioManager.STREAM_ALARM, target, 0) } catch (_: Exception) {} result.success(null) Nothing anywhere reads the stream back. getAlarmVolume is used exactly once, as a boolean capability probe at boot — lib/ui/home.dart:222-226 says so verbatim: "getAlarmVolume is a capability probe only: non-null => this platform has a device alarm stream". So the app writes a level, is told "success" whatever happened, and from then on believes the alarm stream holds at least AlarmVolume.floor (0.15) while it may hold 0. The whole reason MODIFY_AUDIO_SETTINGS is declared (AndroidManifest.xml:8, comment at :4-7: "No auto-boost — the stream only ever holds a level the operator chose, floored at 15%") rests on a write whose failure is structurally unobservable. This is the mechanism by which an alarm fails to ring with no trace, which is the audit's definition of BLOCKER.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: a tablet in Do Not Disturb, or under a mobile-device management profile, or on an OEM build that restricts alarm-stream writes, will run a perfect board that makes no sound, and the app will show a clean green state throughout the service.
  • Proposed fix: three changes. (1) In MainActivity.kt:57-59, reply with the failure instead of hiding it: try { audio.setStreamVolume(...); result.success(null) } catch (e: Exception) { result.error("volume_write_failed", e.message, null) }. (2) Read back and return the achieved level so Dart can compare: make setAlarmVolume answer audio.getStreamVolume(AudioManager.STREAM_ALARM).toDouble() / max instead of null. (3) In lib/ui/home.dart:209-218, raise the existing Diag.fail('volume-set', e) to isCritical: true and add a read-back mismatch check — the operator banner already exists and lib/i18n.dart already carries the audioDown string.
  • How to prove the fix: 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.

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

  • Severity: HIGH
  • Location: android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:90-94 (valid at 03a176e); Dart side lib/audio/voice.dart:54 and :84
  • What is wrong: TextToSpeech.setLanguage communicates failure by return code, not by throwing. The bridge discards the code and answers result.success(null); the empty catch below it hides the remaining (cast) failure. On the Dart side both call sites await the channel and ignore the result. An engine with no French voice data therefore reports success, and every announcement for the rest of the service is either silent or read with English phonemes.
  • Evidence: 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').
  • Why it matters for a restaurant kitchen: the spoken announcement is the product (lib/audio/voice.dart:6-10). A French brigade switches the app to French in Settings, the tablet has no French voice data, and the app carries on believing the voice works. Nothing on screen and nothing in the journal says otherwise.
  • Proposed fix: 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 the fix: 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

  • Severity: HIGH
  • Location: lib/audio/voice.dart:165-168 and :134 (valid at 03a176e); Kotlin MainActivity.kt:143-156 and :82-89
  • What is wrong: the order is journal-first, call-second, and the answer is discarded: dart Journal.log(' parole', '"${item.text}"'); await _ch .invokeMethod('speak', {'text': item.text, 'volume': vol}) .timeout(const Duration(seconds: 12)); MainActivity.kt:152-155 answers false when TextToSpeech.speak did not return SUCCESS, and voice.dart:166 never looks at it. The same is true of setVoice: MainActivity.kt:88 answers false, voice.dart:134 discards it, and voice.dart:135 then logs voix: choisie: $best. The on-device journal — the artifact Serge uses to diagnose a service after the fact (lib/journal.dart:1-6) — therefore states that phrases were spoken and voices chosen when neither happened.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: when the pass reports "the app stopped talking", the journal is the only evidence available, and today it will show a clean run of announcements. Every field diagnosis built on it starts from a false premise.
  • Proposed fix: 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 the fix: 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

  • Severity: HIGH
  • Location: lib/alarm_backstop.dart:79-96 and :64 (valid at 03a176e)
  • What is wrong: when requestNotificationsPermission() answers false, init raises the critical banner (:83-87) but still sets _ready = true (:90). Every subsequent sync() and showNow() then schedules and posts notifications the OS will never display, and each success calls Diag.clearCritical('backstop-schedule') (:202) on the way, so the state looks healthy at the scheduling layer. Worse, the check happens once at boot. Android's notification channel is a separate switch the operator can flip at any time — and per Google's own documentation, once created, a channel's behaviour can no longer be set by the app: "After you create a notification channel, you can't change the notification behaviors. The user has complete control at that point." (captures/android_notification_channels.txt:84). The app never calls areNotificationsEnabled() or getNotificationChannels() after init, so a channel muted by a passing cook silences the safety net permanently and silently.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the backstop is the answer to "what if the app is killed". A single long-press-and-mute on a notification, by anyone in the brigade, disarms it for good, and the app keeps reporting that system alarms are active (lib/ui/home.dart:150-151).
  • Proposed fix: (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 the fix: 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

  • Severity: HIGH
  • Location: android/app/src/main/AndroidManifest.xml:13-15; lib/alarm_backstop.dart:80, :192-194, :208-220 (valid at 03a176e)
  • What is wrong: the manifest declares USE_EXACT_ALARM (API 33+, not revocable) and SCHEDULE_EXACT_ALARM with maxSdkVersion="32". On API 31-32 that permission is revocable, and Google documents the consequence: "When the SCHEDULE_EXACT_ALARM permission is revoked for your app, your app stops, and all future exact alarms are canceled." (captures/android_exact_alarms.txt:185). The app never calls canScheduleExactAlarms() before scheduling, and it registers no receiver for ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED (grep -c SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED android/app/src/main/AndroidManifest.xml0). The only degradation path is reactive — alarm_backstop.dart:208-220 catches PlatformException(code: 'exact_alarms_not_permitted') and retries inexact — and it never runs if the alarms were cancelled out from under the process rather than refused at scheduling time.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: an older tablet (Android 12 is still common on cheap kitchen hardware) can be left with the app running, no scheduled alarms at all, and no indication anywhere. On API 33+ this cannot happen, so the exposure is bounded — but it is bounded to exactly the hardware a restaurant is most likely to buy.
  • Proposed fix: 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 the fix: 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 rocker can mute an alarm that is already ringing

  • Severity: HIGH
  • Location: lib/audio/alarm_volume.dart:60-67 (valid at 03a176e)
  • What is wrong: onRunChanged writes the level exactly once, when anyRinging goes from false to true. While a timer stays in ringing, _wasRinging remains true and no further write occurs. A cook who brushes the hardware volume rocker down during an alarm silences every repeat of that alarm (repeats every 2 to 7 s, lib/engine/engine.dart:52-54) until the timer is stopped and a different one starts. The design intent is explicit at alarm_volume.dart:14-16"we never fight the operator by rewriting the level on every heartbeat" — and it is the right call for a slider moved deliberately, but the hardware rocker and the in-app slider are indistinguishable to this code, and only one of them is an operator decision the app should respect.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: during service the rocker gets knocked. The one moment the app must be loudest is the one moment it stops asserting itself.
  • Proposed fix: 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 the fix: 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

  • Severity: HIGH
  • Location: lib/audio/audio.dart (whole file, 116 lines, valid at 03a176e)
  • What is wrong: at the pinned commit exactly two of 38 executable lines ran under the suite — lines 83 and 84, the body of static String assetFor(String tone). Everything else was dark: the AudioContext that puts playback on the alarm stream (:23-35), player construction (:37-42), init() including the pool loop and the vibrator probe (:44-54), all of _play including the "player pool not ready" report and the Diag.clearCritical recovery (:58-78), ringtone / stepChime / click (:86-92), and every haptic (:96-115). Every WAV in the product goes through _play. A regression there produces silence, and the suite would have stayed green.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: a silent alarm is the one failure this product cannot survive, and the module that makes the noise was the one module nothing exercised.
  • Proposed fix: 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 the fix: 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.

S3-F10 — a ringing tone cannot be stopped, and all timers share one ringtone player

  • Severity: MEDIUM
  • Location: lib/audio/audio.dart:19, :62, :86-87 (valid at 03a176e)
  • What is wrong: SoundBox exposes no way to stop a sound. Its public surface is init, assetFor, ringtone, stepChime, click and four haptics — there is no stop, release or dispose anywhere in the file. When the cook acknowledges an alarm, Engine.stopTimer reaches host.onStopped(id)voice.stopFor(id) (lib/ui/home.dart:338), which cancels the voice only; the WAV plays out. The comment at audio.dart:14-18 records that a tone can now run four seconds. Separately, the dedicated AudioPlayer? _ring (:19) is a single instance shared by every timer, so when two dishes ring at once the second one's tone cuts the first's rather than layering — the exact behaviour the comment describes as desirable within one timer, applied across all of them.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the cook taps the tile to acknowledge, and the tone keeps going for up to four seconds. On a board of eight timers during a rush that reads as "the app did not register my tap", and cooks start double-tapping, which the 260 ms disambiguation window (lib/engine/engine.dart:51) turns into a different action.
  • Proposed fix: 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 the fix: 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"

  • Severity: MEDIUM
  • Location: lib/audio/audio.dart:49-53, :96-101, :105-109 (valid at 03a176e)
  • What is wrong: two separate problems in one small block. (a) Vibration.vibrate(...) returns a Future that is never awaited and never given a catchError, at :99 and :107. A platform failure becomes an unhandled asynchronous error that reaches neither Diag nor the journal — the only unguarded platform call in the entire audio layer. (b) The try/catch around Vibration.hasVibrator() at :49-53 is unreachable: in vibration_platform_interface 0.1.2 the method never throws (every branch is already wrapped and returns false), and it never asks the vibrator — it reads device_info_plus's isPhysicalDevice. _canVibrate therefore means "this is a physical Android or iOS device", not "this device can vibrate", which is exactly wrong for the wall-mounted tablets a kitchen uses.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: haptics are the secondary alarm channel when the board is held or when the room is at its loudest. A tablet with no motor reports true and every vibration silently fails; the operator is told nothing and the fallback the design assumes is not there.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/audio/voice.dart:161-173 (valid at 03a176e)
  • What is wrong: the 12 s .timeout is a correct and welcome unjam — without it a vendor engine that never calls back would hang the queue for the rest of the service. But the resulting Diag.fail('voice-speak', 'native speak timed out (engine wedged?)') at :170 is not critical, so a device whose engine wedges on every utterance produces one announcement every twelve seconds with no banner, no visible degradation and nothing on the board to explain it. Contrast voice-init at :50 and :66, which are correctly isCritical: true.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the voice is what lets a cook keep their hands and eyes on the pass. Losing it should look like losing it.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/audio/voice.dart:26, :36, :180-188 (valid at 03a176e)
  • What is wrong: _queue is a plain List<_QueueItem> with no cap. The only thing that removes entries besides speaking them is _dropStale(), which runs inside _drain (:151) and drops items older than staleMs. staleMs is a static const int with no injection point and _QueueItem.at is stamped from DateTime.now() (:20-21), which fake_async does not control, so the staleness sweep cannot be exercised by any test that does not wait twenty real seconds. The queue is therefore both unbounded and, on its trim path, untestable.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: an eight-timer board in a rush, with the engine stalled behind one wedged utterance (S3-F12), accumulates announcements no one will ever want to hear, and the mechanism meant to discard them is the one part of the queue nobody has ever run.
  • Proposed fix: 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 the fix: 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 — a background notification cannot tell the cook which batch is ready

  • Severity: MEDIUM
  • Location: lib/ui/home.dart:300; lib/alarm_backstop.dart:259 versus :113 and :186 (valid at 03a176e)
  • What is wrong: the scheduled backstop titles its notification with engine.labelFor(t.id) (alarm_backstop.dart:113 feeds _schedule's name, used at :186), which produces Fries [lot 2] for a batch clone (lib/engine/engine.dart:132-138). The immediate notification path, showNow, titles it '⏰ ${t.name}' (alarm_backstop.dart:259) from the TimerDef handed in at lib/ui/home.dart:300 — and Engine.viewList() builds a clone as t.copyWithId(c.id) (engine.dart:121), so a clone carries the parent's name. Three batches of fries ringing while the app is backgrounded therefore produce three notifications all titled ⏰ Fries.
  • Evidence: 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'.
  • Why it matters for a restaurant kitchen: batches exist precisely because a station runs the same dish several times over. A notification that says "Fries" when three pans are down tells the cook nothing they did not already know, and the app's own journal convention (engine.dart:127-131) exists to avoid exactly this ambiguity.
  • Proposed fix: change showNow's signature to take the label the caller already has — showNow(TimerDef t, String body, {required String label}), titled '⏰ $label' — and pass engine.labelFor(t.id) from lib/ui/home.dart:300 and lib/alarm_backstop.dart:245.
  • How to prove the fix: the test named above changes its final expectation from '⏰ Fries' to '⏰ Fries [lot 2]'. It passes with '⏰ Fries' today (proof recorded) and fails after.

S3-F15 — SoundBox never releases its five players, and that leak is observable

  • Severity: MEDIUM
  • Location: lib/audio/audio.dart:44-54; lib/ui/home.dart:235-243 (valid at 03a176e)
  • What is wrong: init() builds four pooled AudioPlayers plus one dedicated ringtone player, and the class has no dispose, release or teardown of any kind; _HomeScreenState.dispose() cancels the ticker and the tap timers but touches neither sounds nor voice. Because audioplayers attaches a FramePositionUpdater that re-registers a scheduler frame callback on every frame while a player is in the playing state (audioplayers-6.8.1/lib/src/position_updater.dart:72-83), a player left playing keeps a scheduler callback alive for the process lifetime.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: a board runs for a fourteen-hour service. Five media players and a per-frame callback that is never torn down is the kind of slow drain that shows up as the app being killed by the OS at hour nine — which is precisely when the backstop becomes the only alarm.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM — STATIC ANALYSIS ONLY. No Xcode on this machine; never compiled or run.
  • Location: ios/Runner/AppDelegate.swift:157-175 and :186-192 (valid at 03a176e)
  • What is wrong: speak cancels any in-flight utterance and calls completeAllSpeaks() (:161-164), which dispatches its work asynchronously onto the main queue. It then synchronously inserts the new utterance's FlutterResult into pendingSpeaks (:173) and starts speaking. Because platform-channel handlers run on the main thread, the block enqueued at :163 runs after line 173 and executes pendingSpeaks.removeAll() followed by pending.values.forEach { $0(false) } — completing the utterance that was just started, with false, before a single word has been spoken. VoiceBox._drain then treats the announcement as finished, sets _speaking = false and schedules the next one 300 ms later (lib/audio/voice.dart:174-177), so announcement N+1 cuts announcement N mid-word. The Kotlin side does not have this hazard: completeAllSpeaks is only reachable from the stop handler (MainActivity.kt:106), never from speak.
  • Evidence: 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) } } }
  • Why it matters for a restaurant kitchen: on iPad, whenever two dishes ring close together, the first announcement is cut off part-way. "The fish is—" is worse than no announcement, because the cook acts on a name they half-heard.
  • Proposed fix: 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 the fix: 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.

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

  • Severity: HIGH — the Dart half is proven by test; the iOS half is static analysis only.
  • Location: ios/Runner/AppDelegate.swift:54-59; lib/ui/home.dart:209-218; lib/audio/alarm_volume.dart:18-22 (valid at 03a176e)
  • What is wrong: iOS answers nil to getAlarmVolume, which is a deliberate and correct design decision (AppDelegate.swift:16-17), and Dart falls back to app-level gain (home.dart:214-217: sounds.vol = level; voice.vol = level;). But app-level gain is a multiplier on the device's playback volume. AlarmVolume.floor guarantees the multiplier never goes below 0.15; it guarantees nothing about the thing it multiplies. The file comment at alarm_volume.dart:18-22 states the floor exists because "An alarm board the cook cannot hear is not a product" — on iOS that guarantee simply does not hold, and nothing in the app notices. The .playback session category (AppDelegate.swift:145, and audio.dart:31-34) correctly defeats the physical silent switch, but it does not defeat the volume buttons.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: an iPad whose volume was turned down by whoever used it last runs the whole service silently and reports itself healthy. This is the single most important open question for whether the product can be sold for iPad at all, and it currently has no answer in the code.
  • Proposed fix: 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 the fix: 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-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

  • Severity: LOW (informational; recorded because the stream was asked to check these specifically and found nothing wrong)
  • Location: lib/alarm_backstop.dart:181, :143-148, :208-220; android/app/src/main/AndroidManifest.xml:46-56 (valid at 03a176e)
  • What is wrong: nothing. Each of the following was checked and holds.
  • Scheduling a deadline in the past. Guarded at :181 (if (at <= DateTime.now().millisecondsSinceEpoch + 500) return;), with on ArgumentError at :204-207 for the race. Already covered by test/backstop_test.dart:88-96.
  • Reboot. The manifest registers com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver for BOOT_COMPLETED, MY_PACKAGE_REPLACED and both QUICKBOOT actions (AndroidManifest.xml:48-56), and the receiver calls FlutterLocalNotificationsPlugin.rescheduleNotifications(context) (ScheduledNotificationBootReceiver.java:20), which replays the persisted schedule (FlutterLocalNotificationsPlugin.java:227-244). The wiring is real, not decorative. On the next app launch Backstop.init calls cancelAll() (:89) and _boot immediately re-syncs from persisted run state (lib/ui/home.dart:149-152), so the two mechanisms do not fight.
  • Double ring and foreground. The 1.5 s grace introduced by commit 2f353ad is present (_graceMs = 1500, :175) and correct: the engine fires 1200 ms before the nominal deadline (engine.dart:50, :312), so the cancel has about 2.7 s of margin. Verified end to end by the new test S3: a timer that starts RINGING loses its scheduled alarm on the very next sync (no double ring) — the transition to ringing removes the timer from _desired (:104) and produces exactly one cancel.
  • Debounce. Already covered by test/backstop_test.dart:150-190 and re-verified.
  • Chained timers. _desired sums the remaining steps onto endsAt (:107-112); previously 0% covered, now proven exactly by the new test S3: a chained timer is backed at the FINAL step deadline (60 s + 30 s + 45 s plus the 1.5 s grace).
  • Channel payload. New test S3: the scheduled alarm carries max importance, the alarm usage and the raw sound asserts that channelId == 'cadence-alarms', importance == 5, sound == 'cadence_alarm', audioAttributesUsage == 4 (USAGE_ALARM) and fullScreenIntent == true actually reach the platform.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: these are the mechanisms the product's safety net is built from. They were the most likely place to find a defect and they are sound; the backstop's problems are on iOS (S3-F1) and in permission handling (S3-F6, S3-F7), not in its scheduling logic.
  • Proposed fix: none.
  • How to prove the fix: not applicable.

S3-F19 — assetFor carries a rewrite no tone can reach

  • Severity: LOW
  • Location: lib/audio/audio.dart:83-84 (valid at 03a176e)
  • What is wrong: assetFor lower-cases the tone and then rewrites buzzer to buzz. The tone keys in C.tones (lib/ui/theme.dart:45-48) contain Buzz, never Buzzer; Buzzer is only the display label (lib/i18n.dart:20, :34) and never reaches this function. The rewrite is dead code that reads as a live compatibility shim.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: nothing today. It matters for the next person who adds a tone and assumes labels are what gets resolved.
  • Proposed fix: delete the .replaceAll('buzzer', 'buzz'), leaving '${tone.toLowerCase()}.wav'.
  • How to prove the fix: 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.

Section C — Kotlin and Swift parity table

Commit 47f4172 sets the rule: the native code is an adapter, never a second codebase — identical contract on both sides, decisions in Dart. Below, every method on both app-owned channels. The Swift column is static analysis only: there is no Xcode on this machine (flutter doctor[!] Xcode - develop for iOS and macOS, ✗ Xcode installation is incomplete), the file itself carries ⚠️ NOT YET COMPILED at AppDelegate.swift:12-13, and ios/ contains no Podfile, so the iOS target has never been built.

cadence/volume

Method Kotlin (MainActivity.kt) Swift (AppDelegate.swift) Verdict
channel registration :43-44 :50-52, hooked from didInitializeImplicitFlutterEngine :37-48 — matches the Flutter 3.44.8 template at packages/flutter_tools/templates/app/ios.tmpl/Runner/AppDelegate.swift; both AppDelegate.swift and SceneDelegate.swift are members of the Runner target (ios/Runner.xcodeproj/project.pbxproj:284,286) Implemented on both
getAlarmVolume :47-50 — real value from getStreamVolume / getStreamMaxVolume :54-57result(nil) Deliberate stub, documented at AppDelegate.swift:16-17; the Dart fallback at home.dart:214-217 handles null. Correct as a contract, but see S3-F17: the fallback has no floor.
setAlarmVolume :51-60 — writes STREAM_ALARM, clamped to 0..max :58-59result(nil), no-op Deliberate stub, same note.
unknown method :61 result.notImplemented() :60-61 result(FlutterMethodNotImplemented) Parity

cadence/tts

Method Kotlin (MainActivity.kt) Swift (AppDelegate.swift) Verdict
channel registration :65-66 :66-68 Implemented on both
init :68initTts :114-141. Asynchronous TextToSpeech construction, queues concurrent callers in pendingInits, sets USAGE_ALARM and CONTENT_TYPE_SPEECH attributes :122-127, installs an UtteranceProgressListener :128-135, answers ttsReady to all waiters :71-72initTts :138-155. Synchronous: sets AVAudioSession to .playback / .spokenAudio / .mixWithOthers and activates it, answers true or false Implemented on both, semantically different. Kotlin's true means "a speech engine is up"; Swift's true means "an audio session was activated" — it never checks that any voice exists. A device with no voices answers true and then speaks nothing. Behavioural divergence.
getVoices :69-81. tts?.voices, raw Android quality (300/400/500), real isNetworkConnectionRequired. Guarded by catch #2. :74-91. AVSpeechSynthesisVoice.speechVoices(), quality remapped onto the Android scale, network hard-coded false Parity of contract, and the remap is the right call: it keeps voice.dart:104-142 identical on both platforms. Swift does not gate on ttsReady; Kotlin returns an empty list when tts is null. Minor divergence, no consequence.
setVoice :82-89. Matches on Voice.name, assigns tts?.voice, answers Bool. Guarded by catch #3. :93-100. Matches on AVSpeechSynthesisVoice.name, stores identifier, answers Bool Parity. Both answers are discarded by Dart — see S3-F5.
setLanguage :90-94. Applies immediately via tts?.setLanguage; does not reset the selected voice. Guarded by catch #4. :102-107. Stores the tag, resets voiceId to nil, applied at the next utterance Divergence, benign today. VoiceBox.setLocale (voice.dart:80-89) always calls setLanguage then _pickVoice, so both platforms end up with a freshly chosen voice. It would bite anyone who calls setLanguage alone.
setRate :95-99. setSpeechRate(1.0) is Android normal. Guarded by catch #5. :109-118. Scales by AVSpeechUtteranceDefaultSpeechRate and clamps to the platform minimum and maximum Parity of intent, correct platform adaptation.
speak :100-103speak :143-156. QUEUE_FLUSH, KEY_PARAM_VOLUME, result completed later by the utterance listener, immediate false on a failed start. Guarded by catch #7. :120-125speak :157-175. Stops any in-flight utterance, sets volume, rate and voice, result completed later by the delegate Implemented on both, Swift has a defect — see S3-F16: the asynchronous completeAllSpeaks() can complete the new utterance's result immediately.
stop :104-108. tts?.stop() (catch #6) then completeAllSpeaks() then result.success(null) :127-130. synth.stopSpeaking(at: .immediate) then completeAllSpeaks() then result(nil) Parity
unknown method :109 result.notImplemented() :132-133 result(FlutterMethodNotImplemented) Parity
utterance completion completeSpeak :160-162 — hops to the main Handler before touching the Result; completeAllSpeaks :164-169 completeSpeak :179-184DispatchQueue.main.async, removal before call; completeAllSpeaks :186-192; delegate :195-207 Parity of intent, defect in the Swift ordering (S3-F16).
teardown onDestroy :171-175, tts?.shutdown() (catch #8) absent — no applicationWillTerminate, no synth.stopSpeaking on teardown Missing on iOS. Low consequence: iOS reclaims AVSpeechSynthesizer with the process.

Parity summary

  • Both channels are structurally complete on both platforms. Every method the Dart side invokes has a handler on both sides; there is no silently missing implementation on the two app-owned channels. Verified against the Dart call sites voice.dart:48, 54, 55, 84, 106, 134, 167, 196 and home.dart:211, 226.
  • Parity is NOT complete overall, for one reason that has nothing to do with these two files: the notification backstop, which is where the alarm guarantee actually lives when the app is not in front, is Android-only (S3-F1, S3-F2). lib/alarm_backstop.dart is a third de-facto platform surface, and it has no iOS half.
  • Two behavioural divergences (init semantics, the setLanguage voice reset) and one Swift defect (S3-F16) are listed above.
  • None of the Swift column has ever been compiled. flutter doctor on this machine reports Xcode incomplete (proof/00_baseline/doctor.txt), and ios/ has no Podfile (ls ios/Flutter Runner Runner.xcodeproj Runner.xcworkspace RunnerTests). Every statement about iOS runtime behaviour in this report is a reading of source and is labelled as such.

Section D — what MUST be verified on real hardware before shipping

No Android tablet is attached and there is no Xcode on this machine (proof/00_baseline/doctor.txt). Ordered by consequence. Each protocol produces a binary answer.

D1 — Does an iOS build ring at all from the background? (blocks the App Store goal) On a Mac with Xcode: flutter build ios --debug, run on a physical iPad. Start a 3-minute timer, press the Home button, lock the screen, put the iPad face down. Expected today, from S3-F1 and S3-F2: nothing happens at T+3:00, and reopening the app shows a red banner naming backstop. Capture: a screen recording plus the exported journal (Settings, journal export, lib/journal.dart:207-237). Settles: whether iOS is shippable at all before S3-F1 is fixed.

D2 — Does setStreamVolume on STREAM_ALARM ever throw on the target tablet? (S3-F3) On the Android tablet, over adb: (a) adb shell settings put global zen_mode 1 to enable Do Not Disturb; (b) launch the app and move the Settings slider from 100% to 15% and back; (c) adb shell dumpsys audio | grep -A3 "STREAM_ALARM" before and after each move; (d) adb logcat -s AndroidRuntime:E flutter:* throughout. Binary result: the alarm-stream index either follows the slider or it does not. Repeat under any mobile-device-management or work profile the customer deploys. Settles whether S3-F3's swallowed exception fires in practice, that is, whether the 15% floor is real on the shipping hardware.

D3 — Is an iPad audible with the volume buttons down? (S3-F17) On a physical iPad: set the ringer switch to silent and press volume-down to zero. Fire a timer. Record whether any sound is produced through the .playback session. Repeat with volume at 25%. Settles whether the iOS app needs the outputVolume warning proposed in S3-F17 before it can be sold for iPad.

D4 — Does the backstop survive a reboot on the target tablet? (confirms S3-F18) Start a 20-minute timer, confirm the journal line secours: alarme systeme posee … from lib/alarm_backstop.dart:197-199, then adb reboot. Do not open the app. Wait past the deadline. Binary result: the full-screen alarm notification either appears or it does not. The plugin's boot receiver is verifiably wired (S3-F18), but OEM battery managers on cheap tablets frequently disable boot receivers for apps that were not opened after boot.

D5 — Does the iOS speech queue cut announcements short? (S3-F16) On a physical iPad with two timers ending 2 s apart, record audio. Binary result: the first announcement either completes or is cut mid-word.

D6 — Does a full-screen intent still fire on Android 14 and later? AndroidManifest.xml:16 declares USE_FULL_SCREEN_INTENT and _channel sets fullScreenIntent: true (alarm_backstop.dart:53). On an Android 14 or 15 tablet, background the app, let a timer expire, and record whether the notification takes over the screen or merely appears in the shade. Both outcomes are acceptable for the product; the answer determines the store listing claim and the Play Console declaration for that permission.

D7 — Is the alarm audible over a working kitchen? (product, not code) On the actual hardware in an actual kitchen with the extraction hood running, at the 15% floor and at 100%, measure whether each of the twelve tones is heard at the pass. This is the assumption the entire floor constant rests on (lib/audio/alarm_volume.dart:26-28) and nothing in the repository records it ever having been measured.


Coverage manifest

Every file in this stream's scope, its size, and what was checked in it.

File Lines What was checked
lib/audio/audio.dart 116 Read in full. Audited: the AudioContext alarm-stream and .playback configuration (:23-35); player construction and the four-plus-one pool (:37-54); every branch of _play — player selection, the null-pool report, volume clamping, Diag.clearCritical recovery, the catch (:58-78); assetFor against all 12 C.tones entries; ringtone / stepChime / click; all four haptics and the _canVibrate gate. Cross-checked the vibration 3.2.0 and audioplayers 6.8.1 package sources. Coverage lifted 5.26% → 97.37% by 14 new tests. Findings: S3-F9, S3-F10, S3-F11, S3-F15, S3-F19.
lib/audio/voice.dart 204 Read in full. Audited: init including the cold-start drain and _discardQueue (:45-78); setLocale (:80-89, previously 0% covered); _pickVoice scoring on language, quality and network (:104-142); enqueue and _drain including the _gen cancellation guard, the 12 s timeout and the 300 ms beat (:144-178); _dropStale (:180-188); stopFor including its .catchError (:191-203). Traced the queue for deadlock and for a dropped final announcement — neither exists: _drain sets _speaking synchronously before its first await, so a burst cannot double-speak (proven by test); stopFor resets _speaking itself on the path where _drain's early return skips it; and the 12 s timeout is the backstop against a wedged engine (proven by test). Coverage 86.36% → 96.59% by 10 new tests. Findings: S3-F4 (Dart half), S3-F5, S3-F12, S3-F13.
lib/audio/alarm_volume.dart 68 Read in full. Already 100% line-covered at baseline. Verified the existing tests are real by mutation (floor 0.15 → 0.0 turns test/volume_test.dart red — mutations/M1_volume_floor.patch and .after.txt, EXIT_CODE=1), then audited the rule rather than the code: sane clamping, the boot assertion, write-through on slider move, and the rising-edge-only re-assertion. The rule itself has a gap — S3-F8. Also established that nothing in the suite connected AlarmVolume.apply to the real platform, and closed that with 7 new HomeScreen tests.
lib/alarm_backstop.dart 279 Read in full. Audited: init including both permission requests, cancelAll and the catch (:69-97); _desired including the chain final-deadline sum (:100-116, previously 0% covered); sync's immediate-cancel, immediate-arm and debounce split (:122-149); _flushSchedules (:154-168); _schedule including the past-deadline guard, the ArgumentError branch, the exact-to-inexact degradation and its recursive retry (:177-221); _cancel (:223-233); onBackground, showNow and onForeground (:238-278, all previously 0% covered). Cross-checked the flutter_local_notifications 22.1.0 Dart and Android sources for iOS initialisation, boot rescheduling and notification-channel creation. Coverage 78.00% → 94.00% by 9 new tests. Findings: S3-F1, S3-F6, S3-F7, S3-F14, and the no-defect record S3-F18. Six lines remain uncovered (206 207 231 245 262 276): all are error branches reachable only from a platform failure the mock cannot produce without duplicating an already-tested path.
android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt 176 Read in full. All 8 catch (_: Exception) sites enumerated in Section A with line numbers, throw sources, downstream behaviour and who is informed (answer: nobody, 8 of 8). Also audited: the unguarded getStreamMaxVolume at :45 (evaluated on every call, including notImplemented), the unguarded (call.arguments as Number) cast at :55, the pendingInits queueing in initTts (:114-141), the binder-thread to main-thread hop in completeSpeak (:160-162), and onDestroy not completing pending speaks. Confirmed zero Log. calls in the file. Findings: S3-F3, S3-F4, S3-F5, plus Section A.
ios/Runner/AppDelegate.swift 207 Read in full, static analysis only (no Xcode — proof/00_baseline/doctor.txt; the file's own header says ⚠️ NOT YET COMPILED). Method-by-method parity table in Section C. Verified the engine hook matches the Flutter 3.44.8 template and that both AppDelegate.swift and SceneDelegate.swift are members of the Runner target in project.pbxproj. Findings: S3-F16, S3-F17, plus the parity divergences in Section C.
lib/ui/home.dart (channel call sites only) 722, of which 7 audited Audited the two cadence/volume call sites and their error handling: the _volumeChannel declaration :67, _applyAlarmLevel :209-218 (.catchError into a non-critical Diag), _initSystemVolume :220-233 (try/catch into a non-critical Diag, capability-probe-only semantics). Also traced onAlarmFire :283-303 and persistRun :251-258 to establish the volume-before-ringtone ordering (proven by test). The rest of the file belongs to another stream. Coverage 0.00% → 36.67% as a side effect of the 7 new HomeScreen tests.
android/app/src/main/AndroidManifest.xml 79 Read in full. Checked every declared permission against its consumer: VIBRATE and WAKE_LOCK; MODIFY_AUDIO_SETTINGS (S3-F3); POST_NOTIFICATIONS (S3-F6); USE_EXACT_ALARM plus SCHEDULE_EXACT_ALARM maxSdkVersion=32 (S3-F7); USE_FULL_SCREEN_INTENT (device protocol D6); RECEIVE_BOOT_COMPLETED (verified wired — S3-F18). Also verified the TTS_SERVICE <queries> entry at :75-77 is present and correct for Android 11+ package visibility, which is what allows the cadence/tts bridge to bind an engine at all.
ios/Runner/Info.plist 63 Read in full. Enumerated every top-level key; established the absence of UIBackgroundModes (S3-F2). No privacy-usage strings are required by the two app-owned channels: neither uses the microphone nor speech recognition.
ios/Runner/SceneDelegate.swift 6 Read in full. Empty FlutterSceneDelegate subclass matching the Flutter 3.44.8 template; referenced correctly from Info.plist's UIApplicationSceneManifest and from project.pbxproj. No defect.
test/backstop_test.dart 191 Read in full (R8). All six tests exercise real behaviour, verified by the M2 mutation. The gaps they leave — chain deadlines, showNow, onForeground, denied permission, iOS — are now covered by s3_backstop_test.dart.
test/voice_test.dart 197 Read in full (R8). All eight tests exercise real behaviour. The gaps — setLocale, the 12 s timeout, the discarded native booleans, queue bounds — are now covered by s3_voice_test.dart.
test/volume_test.dart 188 Read in full (R8). Verified real by the M1 mutation. The gap — the rule was never connected to the platform — is now covered by s3_volume_channel_test.dart.
Packages read as evidence flutter_local_notifications 22.1.0 (lib/src/flutter_local_notifications_plugin.dart, android/.../FlutterLocalNotificationsPlugin.java, android/.../ScheduledNotificationBootReceiver.java); audioplayers 6.8.1 (lib/src/audioplayer.dart, audio_cache.dart, global_audio_scope.dart, position_updater.dart); audioplayers_platform_interface 7.2.0; vibration_platform_interface 0.1.2 (lib/src/method_channel_vibration.dart).

Artefacts produced by this stream

Path What it is
proof/01_findings/S3/01_analyze_with_s3_tests.txt flutter analyze with all S3 tests present — No issues found!, EXIT_CODE=0
proof/01_findings/S3/02_test_full_suite_with_s3.txt Full suite — 163 passed, EXIT_CODE=0
proof/01_findings/S3/03_test_coverage_with_s3.txt, 03_lcov_with_s3.info Coverage run and raw lcov
proof/01_findings/S3/04_test_restored_green.txt Suite green after every mutation was reverted
proof/01_findings/S3/tests/s3_backstop_test.dart 9 tests
proof/01_findings/S3/tests/s3_audio_test.dart 14 tests
proof/01_findings/S3/tests/s3_voice_test.dart 10 tests
proof/01_findings/S3/tests/s3_volume_channel_test.dart 7 tests — HomeScreen and cadence/volume end to end
proof/01_findings/S3/mutations/M1_volume_floor.{patch,after.txt} AlarmVolume.floor 0.15 → 0.0; test/volume_test.dart red
proof/01_findings/S3/mutations/M2_backstop_chain.{patch,after.txt} chain sum neutered; chain test red
proof/01_findings/S3/mutations/M3_audio_critical.{patch,after.txt} isCritical dropped; audio critical test red
proof/01_findings/S3/mutations/M4_voice_timeout.{patch,after.txt} 12 s → 600 s; timeout test red
proof/01_findings/S3/captures/android_audiomanager.txt developer.android.com AudioManager, retrieved 2026-08-04
proof/01_findings/S3/captures/android_texttospeech.txt developer.android.com TextToSpeech, retrieved 2026-08-04
proof/01_findings/S3/captures/android_notification_channels.txt developer.android.com notification channels, retrieved 2026-08-04
proof/01_findings/S3/captures/android_exact_alarms.txt developer.android.com exact alarms, retrieved 2026-08-04

To land the four test files in the repo, pubspec.yaml needs three dev_dependencies that are already present in pubspec.lock as transitive packages: audioplayers_platform_interface: ^7.2.0, vibration_platform_interface: ^0.1.2 and fake_async: ^1.3.0.

S3 refutation — audio, voice, alarm backstop and native bridgesagent_reports/S3_refute.md · raw .md

S3 refutation — audio, voice, alarm backstop and native bridges

Refuter, fresh context. Governing rule: R5. Test audit standard: R8.

Subject read-only at 03a176e72ef0075eec86b8915cbe6e93042a3b9d. All experiments on a scratch working copy — a cp -R of the pinned tree including .git, so it is a git repository of its own and proof/run_and_record.sh [not published] resolves REPO to the copy, not to the workspace root. Verified: every proof file this stream wrote carries REPO: a scratch working copy and GIT_HEAD: 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

S3's own proof files do not have the workspace-root defect either. Every header in proof/01_findings/S3/ reads REPO: a scratch working copy, GIT_HEAD: 03a176e…, TREE_STATE: DIRTY (6 path(s) modified) listing exactly the four new test files plus pubspec.yaml/pubspec.lock. That check passes.

The copy was a scratch tree under tmp/ and has since been cleaned; every artefact it produced — 26 mutation patches, 26 whole-suite reporter streams, the harness, the machine summary, the new test file, the AOSP capture and the three recorded runs — is preserved under proof/01_findings/S3_refute/ and is replayable from there.

Raw proof for this refutation: proof/01_findings/S3_refute/.


Verdict summary

Claim under attack Verdict
S3-F1 — iOS backstop dead, permanent red banner (BLOCKER) CONFIRMED
S3-F2 — iOS cannot ring from the background (BLOCKER) CONFIRMED
S3-F3 — setStreamVolume failure swallowed, 15% floor unverifiable (BLOCKER) CONFIRMED as a BLOCKER, REFUTED on mechanism — the cited SecurityException cannot fire at this call site; 2 of its 3 proposed fixes are inert
"8 of 8 Kotlin catch sites swallow the failure with nobody informed" CONFIRMED, site by site
Kotlin/Swift parity table (Section C) CONFIRMED, method by method, every line reference valid
Measured coverage effect of S3's 40 tests CONFIRMED, reproduced to the digit
S3's 40 new tests are real PARTLY REFUTED — 3 of 25 sampled are coverage theatre
S3's four mutation proofs meet R8 REFUTED — none is a whole-suite --reporter=json run

Counts: 12 claims tested — 9 CONFIRMED, 3 REFUTED (one of them a partial). 3 of 3 BLOCKERs survive. 22 of 25 sampled tests meet the R8 standard. 5 findings contributed.


Part 1 — the three BLOCKERs

BLOCKER 1 — S3-F3, MainActivity.kt:57-58 — CONFIRMED as BLOCKER, mechanism REFUTED

The code is exactly as quoted. Verbatim at 03a176e, android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:51-60:

                    "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)
                    }

The Dart side never reads the stream back — confirmed at lib/ui/home.dart:220-233, where getAlarmVolume is used only as _systemVolumeOk = await … != null, with the comment "getAlarmVolume is a capability probe only". So the app writes a level, is told success unconditionally, and never learns what the stream actually holds. The BLOCKER stands on R13's third clause (fails to ring an alarm, with no trace).

But S3's reachability evidence is wrong, and it matters. S3 justified the severity with the AudioManager javadoc: "SecurityException if the volume change triggers a Do Not Disturb change and the caller is not granted notification policy access". That branch cannot be taken by the call this app makes. From the AOSP implementation, Android 14 release branch (proof/01_findings/S3_refute/captures/aosp_audioservice_setStreamVolume.txt, retrieved 2026-08-04 from https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android14-release/services/core/java/com/android/server/audio/AudioService.java?format=TEXT, sha256 2a593c327e7a6ee5270b898cc0b1aeee0a95ae7a85d17c8b2647bcef3daa8597):

AudioService.setStreamVolume, lines 4496-4504:

        if (isAndroidNPlus(callingPackage)
                && wouldToggleZenMode(getNewRingerMode(streamTypeAlias, index, flags))
                && !mNm.isNotificationPolicyAccessGrantedForPackage(callingPackage)) {
            throw new SecurityException("Not allowed to change Do Not Disturb state");
        }

        if (!volumeAdjustmentAllowedByDnd(streamTypeAlias, flags)) {
            return;
        }

getNewRingerMode (lines 3745-3765) returns the unchanged current mode unless the caller passes FLAG_ALLOW_RINGER_MODES or targets the UI-sounds stream:

        if (((flags & AudioManager.FLAG_ALLOW_RINGER_MODES) != 0) ||
                (stream == getUiSoundsStreamType())) {
            …
        }
        return getRingerModeExternal();

The app passes flags = 0 (MainActivity.kt:57, third argument), and getUiSoundsStreamType() returns mStreamVolumeAlias[AudioSystem.STREAM_SYSTEM] (line 5176-5179) — never STREAM_ALARM. So getNewRingerMode returns the current mode, and wouldToggleZenMode(current) is false by construction (lines 3781-3790 compare the argument against getRingerModeExternal() in both directions). The catch (_: Exception) at :58 is very probably dead code for this call site.

The real silent-failure paths are worse than an exception, because no exception handling on either side can ever see them — all three are bare returns inside AudioService:

Line Condition Effect
4466-4468 mUseFixedVolume (fixed-volume device policy) silent no-op
4491-4494 checkNoteAppOp(STREAM_VOLUME_OPS[STREAM_ALARM] = OP_AUDIO_ALARM_VOLUME, …) denied — the device-owner / MDM path silent no-op
4502-4504 !volumeAdjustmentAllowedByDnd(STREAM_ALARM, 0) silent no-op

And volumeAdjustmentAllowedByDnd (lines 4613-4626) is exactly the Do Not Disturb case S3 was reaching for, only it returns false instead of throwing:

    private boolean volumeAdjustmentAllowedByDnd(int streamTypeAlias, int flags) {
        switch (mNm.getZenMode()) {
            case Settings.Global.ZEN_MODE_OFF:
                return true;
            case Settings.Global.ZEN_MODE_NO_INTERRUPTIONS:
            case Settings.Global.ZEN_MODE_ALARMS:
            case Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS:
                return !isStreamMutedByRingerOrZenMode(streamTypeAlias)
                        || isUiSoundsStreamType(streamTypeAlias)
                        || (flags & AudioManager.FLAG_ALLOW_RINGER_MODES) != 0;
        }

and updateZenModeAffectedStreams (lines 6724-6752) puts STREAM_ALARM into that muted set under Do Not Disturb total silence, and under priority only whenever the policy omits PRIORITY_CATEGORY_ALARMS:

        if (zenMode == Settings.Global.ZEN_MODE_NO_INTERRUPTIONS) {
            zenModeAffectedStreams |= 1 << AudioManager.STREAM_ALARM;

Consequence for the fix. S3-F3 proposes three changes. Fix (1) — replying result.error(…) from the catch — fixes nothing, because nothing throws. Fix (3) — raising Diag.fail('volume-set', e) to isCritical: truealso fixes nothing, for an unrelated reason proven below (S3R-F3). Only fix (2), reading getStreamVolume(STREAM_ALARM) back and comparing, detects any of the three silent returns, and it is the one the fix list ranks second. The device protocol D2 is still the right test and is unaffected.

BLOCKER 2 — S3-F1, the iOS backstop — CONFIRMED

Verified against the actual pub-cache source, flutter_local_notifications-22.1.0, lib/src/flutter_local_notifications_plugin.dart:142-147:

    } else if (defaultTargetPlatform == TargetPlatform.iOS) {
      if (settings.iOS == null) {
        throw ArgumentError(
          'iOS settings must be set when targeting iOS platform.',
        );
      }

The throw is unconditional, it precedes resolvePlatformSpecificImplementation, and it is not tolerated anywhere: initialize is async, so the ArgumentError arrives as a rejected future at lib/alarm_backstop.dart:72, is caught at :92-96, and sets _ready = false plus Diag.fail('backstop-init', e, isCritical: true). backstop-init is one of the six scope prefixes the banner can render (home.dart:685-686, scope.startsWith('backstop')i18n.call('backstopDown')), so the permanent red banner is real.

S3's test re-ran green on my copy. I then proved it is a real test rather than an accidental pass: mutation B4 adds iOS: DarwinInitializationSettings() to the InitializationSettings at alarm_backstop.dart:73-75 and the named test goes red with result: "failure", whole-suite --reporter=json, failing set of exactly one, tree clean after revert (proof/01_findings/S3_refute/mutations/B4.patch, raw_json/B4.json).

iOS is broken, not degraded: _ready false ⇒ sync, showNow, onBackground, onForeground all return at their guards, and _details (:61-62) has no iOS: block either, so a repaired init alone would still schedule soundless notifications.

BLOCKER 3 — S3-F2, an iOS build cannot ring once it leaves the screen — CONFIRMED

grep -c UIBackgroundModes ios/Runner/Info.plist0. The 18 top-level keys are exactly the list S3 gives; none is UIBackgroundModes. The alarm depends on a 150 ms Timer.periodic (lib/ui/home.dart:154), and the designed compensation is the notification backstop, which BLOCKER 2 proves inert on iOS. The two compose as stated. WakelockPlus.enable() (lib/main.dart:28) keeps the screen awake, not a suspended app scheduled. No refutation available; the finding stands.


Part 2 — "8 of 8 Kotlin catch sites swallow the failure with nobody informed" — CONFIRMED

grep -c "catch (_: Exception)" MainActivity.kt8, at lines 58, 79, 87, 92, 97, 105, 151, 172 — exactly S3's list. grep -c "Log\." MainActivity.kt0, run by me.

I opened each of the eight and traced its consumer. One correctly-handled site would have broken the claim; there is none.

# Line Reply after the catch Who could learn
1 58 result.success(null) (:59) nobody — the Dart future completes normally, so .catchError at home.dart:210-212 cannot fire
2 79 result.success(emptyList) (:80) nobody — _pickVoice (voice.dart:104-142) iterates an empty list, leaves best == null, and writes the journal line voix: aucune voix adaptee, byte-identical to the legitimate no-match case
3 87 result.success(false) (:88) nobody — voice.dart:134 is await _ch.invokeMethod('setVoice', best); with no assignment; :135 then logs voix: choisie: $best
4 92 result.success(null) (:93) nobody
5 97 result.success(null) (:98) nobody
6 105 completeAllSpeaks() then result.success(null) (:106-107) nobody — the queue is unblocked, but the failure itself is invisible
7 151 pendingSpeaks.remove(id), result.success(false) (:152-155) nobody — voice.dart:166-168 is await _ch.invokeMethod('speak', …).timeout(…) with no assignment
8 172 none (onDestroy) nobody

Sub-claim verified independently: the two returned booleans are discarded. grep -n "invokeMethod('setVoice'\|invokeMethod('speak'" lib/audio/voice.dart134: await _ch.invokeMethod('setVoice', best); and 167: .invokeMethod('speak', {'text': item.text, 'volume': vol}). Neither result is bound.

CONFIRMED, 8 of 8.


Part 3 — audit of S3's 40 new tests (R8 / G6)

This was the highest-value work available, so I over-sampled: 25 of the 40 tests (62.5%), each with its own distinct patch, each run as a whole-suite flutter test --reporter=json, each followed by git checkout -- lib/ and a git status --porcelain check.

Method: proof/01_findings/S3_refute/mutations/mutation_harness.py [not published]. To make R8(d) checkable I first committed S3's four test files and their three dev_dependencies onto a throwaway branch in the copy, so the baseline git status --porcelain is genuinely empty; every "clean after revert" below is therefore an empty status, not "empty except the scaffold". Results: mutations/mutation_results.json; per-mutation raw reporter output: mutations/raw_json/*.json.

Baseline on my copy: 163 tests, 0 failures, flutter analyze → No issues found.

# Target test (file) Mutation Failing set result Clean after revert R8
A1 ringing before init() is CRITICAL (audio) isCritical: criticalfalse exactly 1 failure yes PASS
A2 a NON-alarm sound is NOT critical (audio) _play default criticaltrue exactly 1 failure yes PASS
A3 init() builds 4+1 players (audio) pool loop i < 4i < 3 exactly 1 failure yes PASS
A4 ringtone always uses the SAME player (audio) dedicated ring player bypassed 0 — nothing failed yes FAIL
A4b same test, second attempt _next advanced for the ring player too 0 — nothing failed yes FAIL
A5 every tone resolves to a bundled asset (audio) 'audio/$asset''sfx/$asset' exactly 1 failure yes PASS
A6 assetFor carries a dead Buzzer rewrite (audio) .replaceAll('buzzer','buzz') removed exactly 1 failure yes PASS
A7 a failing vibrator probe is reported (audio) Diag.fail('haptics-detect', e) removed exactly 1 failure yes PASS
A8 fire pattern is [0,200,100,200] (audio) [200,100,200][100,100,200] exactly 1 failure yes PASS
A9 a failing vibration escapes UNHANDLED (audio) .catchError((Object e) {}) added exactly 1 failure yes PASS
A10 no vibrator → hapticClick still fires a system impact (audio) HapticFeedback.lightImpact() deleted 0 — nothing failed yes FAIL
A11 partial init → ringtone falls back to the pool (audio) _ring created before the pool, so it survives the partial init 0 — nothing failed yes FAIL
V1 switching language re-picks a voice (voice) await _pickVoice() removed from setLocale exactly 1 failure yes PASS
V2 a native stop that throws is reported (voice) Diag.fail('voice-stop', e) removed exactly 1 failure yes PASS
V3 the queue is UNBOUNDED (voice) 100-item cap added to enqueue exactly 1 failure yes PASS
V4 a wedged TTS engine unjams after 12 s (voice) seconds: 12seconds: 600 exactly 1 failure yes PASS
V5 setLocale on a dead engine is a no-op (voice) if (!_ready) return; removed exactly 1 failure yes PASS
V6 staleMs is a compile-time constant (voice) staleMs 20000 → 30000 exactly 1 failure yes PASS
B1 showNow uses the SAME id (backstop) _nid… ^ 1 exactly 1 failure yes PASS
B2 scheduled alarm carries max importance (backstop) Importance.maxImportance.high exactly 1 failure yes PASS
B3 onForeground cancels every notification (backstop) for (final n in active)active.skip(1) exactly 1 failure yes PASS
B4 on iOS Backstop.init() fails (backstop) iOS: DarwinInitializationSettings() added exactly 1 failure yes PASS
B5 chained timer backed at the FINAL deadline (backstop) chain sum += t.steps![i].sec*1000+= 0 exactly 1 failure yes PASS
H1 platform with NO alarm stream (volume channel) probe != null!= 999.0 exactly 1 error¹ yes PASS¹
H2 a failing setAlarmVolume is NOT critical (volume channel) Diag.fail('volume-set', e, isCritical: true) exactly 1 error¹ yes PASS¹
H3 MissingPluginException degrades silently (volume channel) Diag.fail('volume-channel', e) removed exactly 1 error¹ yes PASS¹

¹ See S3R-F5 below: flutter_test classifies every testWidgets assertion failure as result: "error", isFailure: false, with the text Test failed. See exception logs above. The tests demonstrably ran and their expect demonstrably failed — the raw JSON carries the TestFailure dump with the exact expectation and the exact line in the test file, e.g. for H1 Expected: empty / Actual: [0.4] at test/s3_volume_channel_test.dart:230:5. These are not the load-time errors R8(b) exists to exclude, so I count them as meeting the intent while flagging the rule defect.

Two runs needed a note. A2's first pass reported 15 failures and only 146 testDone events; the extras were all Failed to load "…": Shell subprocess crashed with SIGTERM (-15) — machine flake, not the mutation. Re-run clean: exactly 1 failure, result: "failure". Both runs are stored (raw_json/A2.json is the re-run).

Score: 22 of 25 sampled tests meet the standard (19 strictly, 3 with the reporter caveat). 3 do not, and all three are in s3_audio_test.dart.


Part 4 — findings S3 missed

S3R-F1 — three of S3's 40 tests assert a behaviour the whole 163-test suite cannot detect

  • Severity: HIGH
  • Location: proof/01_findings/S3/tests/s3_audio_test.dart:220-235, :321-332, :237-250 (valid at the artefact as delivered); production code lib/audio/audio.dart:62, :69, :111-115, :44-54 (valid at 03a176e)
  • What is wrong: each of the three names a specific behaviour in its title and then asserts something that is true whether or not that behaviour exists. Phase 4 is expected to land these files as test/audio_test.dart; landed as-is they would carry three assertions that can never go red, in the one module whose failure mode is a silent alarm. 1. "the ringtone always uses the SAME player so a repeat cuts the previous one instead of layering" — this is the test S3 cites as the proof of finding S3-F10. I removed the dedicated ringtone player from the selection at audio.dart:62 so every ringtone round-robins the pool — the exact defect the test is named after — and not one test in the suite failed. A second, independent mutation (advancing _next for the ring player too, audio.dart:69) also failed nothing. The test's two assertions are plat.sources has length 3 (true for any player assignment) and two clicks land on two distinct pooled players (true whether or not the ring player is in the rotation, because the pool has four members). 2. "no vibrator -> no vibration attempted, but hapticClick still fires a system impact" — the second half has no assertion at all; the body ends with a bare s.hapticClick();. Deleting HapticFeedback.lightImpact() from audio.dart:114 failed nothing. 3. "a partial init … leaves the ringtone without its dedicated player and it silently falls back to the pool" — reordering init() so _ring is created before the pool, which makes the ringtone keep its dedicated player through exactly the partial init the test constructs, failed nothing. All three of the test's assertions (Diag.log empty, one source played, Diag.critical empty) hold either way.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: S3-F10 says two dishes ringing at once cut each other because they share one player. That claim is correct on a reading of audio.dart:19 and :62, but the test offered as its proof does not test it — so the day someone "fixes" S3-F10 by giving each timer its own player, or breaks it by deleting _ring, the suite stays green and the pass finds out during service.
  • Proposed fix: 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 the fix: 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 S3 uses to justify BLOCKER S3-F3 cannot fire at that call site, and 1 of its 3 proposed fixes is inert

  • Severity: HIGH (the finding survives; its evidence and a third of its remedy do not)
  • Location: android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:57 (valid at 03a176e); AOSP AudioService.java:4496-4504, :3745-3765, :3781-3790, :4613-4626, :6724-6752
  • What is wrong: the app calls setStreamVolume(STREAM_ALARM, target, 0)flags = 0. The documented SecurityException is guarded by wouldToggleZenMode(getNewRingerMode(streamTypeAlias, index, flags)), and getNewRingerMode returns the current mode unchanged unless flags & FLAG_ALLOW_RINGER_MODES is set or the stream is the UI-sounds stream (mStreamVolumeAlias[STREAM_SYSTEM]). Neither holds. The Do Not Disturb case S3 was reaching for is real but takes the other branch, volumeAdjustmentAllowedByDnd(...)return; — a silent no-op, no exception, nothing for any catch to see. Two more silent returns sit above it (mUseFixedVolume, and the app-op check that is the device-owner/MDM path S3 lists as its second candidate).
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: identical outcome, different remedy. A tablet in Do Not Disturb accepts the write silently and keeps whatever level the alarm stream already had — so the 15% floor is not applied and nothing anywhere throws. S3-F3 fix (1), replying result.error from the catch, would ship as a no-op and would be believed to have closed the hole.
  • Proposed fix: 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 the fix: 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 scopes, so a critical diagnostic can be raised and shown to nobody — and two of S3's fixes land in exactly that hole

  • Severity: HIGH
  • Location: lib/ui/home.dart:670-704 (the _criticalBanner scope map); live offender lib/engine/store.dart:270 (valid at 03a176e)
  • What is wrong: Diag.fail(scope, e, isCritical: true) adds scope to Diag.critical (lib/diagnostics.dart:34-36), and _criticalBanner turns that set into text with a chain of startsWith tests over exactly six prefixes: voice, audio, save, load, wakelock, backstop. There is no else. Any critical scope outside those six contributes nothing to msgs, and if (msgs.isEmpty) return const SizedBox.shrink(); means the red banner is not built at all. The app already ships one such call: store.dart:270, Diag.fail('migrate-zone-sound', err, isCritical: true) — a failed data migration, declared critical, invisible. And S3's own proposed fixes add two more: S3-F3 fix (3) raises Diag.fail('volume-set', …, isCritical: true) and S3-F17 proposes Diag.fail('volume-device', 'appareil en sourdine', isCritical: true). S3-F3 states the opposite in terms — "the operator banner already exists and lib/i18n.dart already carries the audioDown string". The string exists; nothing routes volume-* to it.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the banner is the app's entire answer to "the cook must SEE when an alarm capability is down" (home.dart:668-669). A capability can currently be declared critically down and produce nothing on screen. Worse for the audit: two of S3's three BLOCKER/HIGH remedies for the volume path route through this hole, so implementing them exactly as specified would leave the operator exactly as uninformed as today while the finding is marked closed.
  • Proposed fix: 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 the fix: 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.

S3R-F4 — S3's four mutation proofs do not meet R8 as written

  • Severity: MEDIUM
  • Location: 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
  • What is wrong: R8 requires four things recorded per mutation. S3 records (c) — four distinct patches against four different tests — and (d) in substance, since each after.txt header prints a TREE_DIFF and 04_test_restored_green.txt shows no M lib/… entry. It does not record (a) or (b): 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 and no result field exists to distinguish "failure" from "error". grep -l "reporter=json" proof/01_findings/S3/** returns nothing.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: nothing directly; it matters for the audit, because four mutation proofs were offered as the warrant for 40 tests and 27 points of coverage. All four claims happen to be true — I re-ran M1's, M2's, M3's and M4's targets to the full standard as V-, B5, A1 and V4 and they pass — but the recorded evidence did not establish that.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: AGENT_RULES.md R8 clause (b); affected artefact proof/01_findings/S3/tests/s3_volume_channel_test.dart (all 7 tests are testWidgets)
  • What is wrong: R8(b) requires the mutated test's JSON result to be "failure" and not "error", on the stated ground that an "error" means the file failed to load and the test never ran. That inference does not hold for widget tests. 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 and the message Test failed. See exception logs above. — for a test that ran and whose expect failed. Applied literally, R8(b) marks every widget test unprovable, and Phase 4 would have to reject all seven s3_volume_channel_test.dart tests including the one that proves the volume-before-ringtone ordering.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: nothing directly; it decides whether the seven tests that connect AlarmVolume to the real platform channel survive Phase 4.
  • Proposed fix: 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 the fix: the stored raw_json/ files are the fixture: under the restated rule H1/H2/H3 pass and A2's flaked first run still fails.

Part 5 — the Kotlin/Swift parity table (Section C) — CONFIRMED

I checked every row against both files at 03a176e. Every line reference is valid and every verdict holds.

Kotlin, all verified: volume channel registration :43-44; getAlarmVolume :47-50; setAlarmVolume :51-60; else -> notImplemented :61; tts registration :65-66; init :68initTts :114-141; getVoices :69-81; setVoice :82-89; setLanguage :90-94; setRate :95-99; speak :100-103speak :143-156; stop :104-108; else :109; completeSpeak :160-162; completeAllSpeaks :164-169; onDestroy :171-175.

Swift, all verified: didInitializeImplicitFlutterEngine :37-48; volume channel :50-52; getAlarmVolume :54-57; setAlarmVolume :58-59; default :60-61; tts channel :66-68; init :71-72initTts :138-155; getVoices :74-91; setVoice :93-100; setLanguage :102-107; setRate :109-118; speak :120-125speak :157-175; stop :127-130; default :132-133; completeSpeak :179-184; completeAllSpeaks :186-192; delegate :195-207. No applicationWillTerminate and no teardown anywhere in the 207-line file — the "missing on iOS" row is right. Target membership confirmed: ios/Runner.xcodeproj/project.pbxproj:284 and :286 list AppDelegate.swift in Sources and SceneDelegate.swift in Sources.

I attacked the three substantive verdicts and could not break any of them:

  • "Both channels are structurally complete on both platforms." I enumerated every method the Dart side invokes — init, setLanguage, setRate, getVoices, setVoice, speak, stop on cadence/tts; getAlarmVolume, setAlarmVolume on cadence/volume — from grep -n "invokeMethod" lib/audio/voice.dart lib/ui/home.dart. Every one has a handler on both sides. No missing implementation.
  • S3-F16 (the async completeAllSpeaks() completing the new utterance). Re-read AppDelegate.swift:161-174 and :186-192. The reasoning holds: completeAllSpeaks() dispatches onto the main queue, the handler is already on the main thread, so the enqueued block runs after line 173 has inserted the new result, and pending.values.forEach { $0(false) } completes it. The Kotlin side genuinely cannot do this: completeAllSpeaks is reachable only from stop (MainActivity.kt:106), and speak (:143-156) never calls it. Confirmed; still unverifiable without Xcode, and S3 labels it as such.
  • The two behavioural divergences (init semantics — Kotlin's true means an engine is up, Swift's means a session activated; and setLanguage resetting voiceId on Swift only) are both present as described and both benign for the current Dart call order.

I found no divergence S3 missed. Attempted and discarded: an ObjectIdentifier key-reuse hazard in pendingSpeaks — not reachable, because entries are removed on completion and a live utterance is retained by the synthesizer, so two live keys cannot collide. Recording it as a non-finding rather than inflating the count.


Part 6 — independent reproduction of S3's measured claims

Run on my own copy with S3's four test files and the three dev_dependencies it names (audioplayers_platform_interface: ^7.2.0, vibration_platform_interface: ^0.1.2, fake_async: ^1.3.0). Recorded: proof/01_findings/S3_refute/01_suite_with_s3_tests.txt (EXIT_CODE=0), 03_analyze.txt (No issues found!, EXIT_CODE=0).

Metric S3 claims I measured
lib/audio/audio.dart 97.37% (37/38) 97.37% (37/38)
lib/audio/voice.dart 96.59% (85/88) 96.59% (85/88)
lib/alarm_backstop.dart 94.00% (94/100) 94.00% (94/100)
lib/audio/alarm_volume.dart 100.00% (14/14) 100.00% (14/14)
lib/ui/home.dart 36.67% (143/390) 36.67% (143/390)
Whole project 71.93% (1386/1927) 71.93% (1386/1927)
flutter test 163 passed, 0 failed 163 passed, 0 failed
flutter analyze 0 issues 0 issues

Every number reproduces exactly. Note that coverage is not evidence of test quality — S3R-F1 shows three of those covered behaviours are unpinned — but the arithmetic is honest.


Coverage manifest (R5)

Every file in S3's scope, and what I checked in it as refuter.

File Lines What I checked
android/.../MainActivity.kt 176 Read in full. Re-derived the eight catch (_: Exception) sites by grep and opened each; traced every one to its result.* reply and then to the Dart consumer, to test the "8 of 8, nobody informed" claim — it holds. Verified grep -c "Log\." → 0 myself. Checked the three points outside a try: getStreamMaxVolume at :45 (evaluated on every call, including notImplemented), the (call.arguments as Number) cast at :55, and Math.round(v*max).coerceIn(0,max) at :56 — a ClassCastException there is converted by the Flutter embedding into a PlatformException on the Dart side, so unlike the eight catch sites it is observable; not a defect, recorded so nobody re-finds it. Went to AOSP AudioService to settle whether setStreamVolume can throw for this call — it cannot (S3R-F2).
ios/Runner/AppDelegate.swift 207 Read in full. Verified every line reference in the parity table and the absence of any teardown hook. Re-derived S3-F16's ordering argument from the source and confirmed it. Probed for a pendingSpeaks key-collision hazard and discarded it as unreachable. No missed divergence. Static analysis only — no Xcode, same as S3.
lib/alarm_backstop.dart 279 Read in full. Verified S3-F1 against the plugin source at flutter_local_notifications_plugin.dart:142-147 and by mutation B4. Verified S3-F6's _ready = true after a refused permission at :83-90. Verified the _graceMs = 1500 guard :175, the past-deadline guard :181, the ArgumentError branch :204-207 and the exact→inexact degradation :208-220. Mutated _nid (B1), Importance.max (B2), the onForeground cancel loop (B3), the iOS settings (B4) and the chain sum (B5) — all five detected. Probed the onForeground/sync interleave at home.dart:189-190 for a cancel-after-rearm race: not reachable, because a ringing timer is excluded from _desired (:104) and showNow is only ever called for ringing timers, so the shown id and the re-armed id cannot be the same. Recorded as a non-finding.
lib/audio/audio.dart 116 Read in full. Eleven of its fourteen new tests mutation-tested (A1-A11). Three of those tests do not detect the behaviour they name (S3R-F1). Verified the AudioContext alarm-stream/.playback block :23-35, the 4+1 player construction :44-54, every branch of _play :58-78, assetFor :83-84, the three sound verbs :86-92 and all four haptics :96-115. Confirmed S3-F11's two claims by reading the block: Vibration.vibrate is unawaited and uncaught at :99 and :107.
lib/audio/voice.dart 204 Read in full. Six of its ten new tests mutation-tested (V1-V6), all six detected. Verified the two discarded booleans at :134 and :167 by grep and by eye. Verified the 12 s timeout :168, the non-critical voice-speak at :170 (S3-F12), the unbounded _queue :26, staleMs as a static const :36 and _QueueItem.at stamped from DateTime.now() :20-21 (S3-F13). Traced _drain's _gen cancellation :160, :174 and stopFor :191-203 for a stranded-queue deadlock — none; S3's negative result stands.
lib/audio/alarm_volume.dart 68 Read in full. Verified S3-F8: onRunChanged :60-67 writes only on the rising edge, and setLevel :53-56 is the only other write path. Verified sane :44-45 handles non-finite input by falling back to 1.0. Did not re-mutate floor — S3's M1 covers it and R8(c) forbids reusing a patch; instead I checked that test/volume_test.dart is the only consumer and that S3's fix proposal (onRepeat) does not exist yet, so its stated red-then-green test is honest.
lib/ui/home.dart (channel + banner) 722, of which ~60 audited Audited the two cadence/volume call sites _applyAlarmLevel :209-218 and _initSystemVolume :220-233 and mutation-tested three of the seven tests over them (H1-H3). Then went one step further than S3 and audited the consumer of every diagnostic in the stream, _criticalBanner :670-704 — which is where S3R-F3 came from. Also read didChangeAppLifecycleState :175-204 and onAlarmFire/onAlarmRepeat/onStopped :283-338 to confirm the volume-before-ringtone ordering S3 proves by test.
lib/diagnostics.dart 54 Read in full — S3 never opened it, though every one of its findings ends in "nobody is informed", and the answer lives here. Diag.fail :28-38 always appends to log and to the journal, and adds to critical only when isCritical. _warned :23 means each scope reaches debugPrint once per process, so a capability that fails repeatedly prints one line ever — worth knowing when reading a field log, not a defect. The critical set is a Set<String> of raw scopes with no validation, which is what makes S3R-F3 possible.
lib/i18n.dart (banner strings only) Checked that voiceDown, audioDown, saveFail, loadFail, screenDown, backstopDown all exist in both locales. There is no volumeDown-style key, confirming that S3-F3's "the string already exists" claim rests on reusing audioDown — which the scope map does not reach.
android/app/src/main/AndroidManifest.xml 79 Spot-checked the permissions S3 cites: MODIFY_AUDIO_SETTINGS :8, USE_EXACT_ALARM and SCHEDULE_EXACT_ALARM maxSdkVersion="32" :13-15, USE_FULL_SCREEN_INTENT :16, RECEIVE_BOOT_COMPLETED :17, the boot receiver :47-58 (the receiver class name is at :49), and the <queries> block :68-78 whose TTS_SERVICE action is at :76. All present. Two of S3's sub-line ranges drift by one or two lines (it cites :48-56 and :75-77); the elements are the ones it names.
android/app/build.gradle.kts 51 Read in full — outside S3's manifest but load-bearing for the backstop's sound. Neither minifyEnabled nor shrinkResources is configured, so the release resource shrinker is off and res/raw/cadence_alarm.wav is not currently at risk; res/raw/keep.xml (tools:keep="@raw/cadence_alarm") is belt-and-braces today and becomes load-bearing the moment anyone enables shrinking. No defect; recorded so the next stream does not have to re-derive it.
ios/Runner/Info.plist 70 Read in full. Enumerated all 18 top-level keys; grep -c UIBackgroundModes → 0. S3-F2 confirmed. (wc -l is 70, not the 63 S3's manifest records — a line-count slip, no consequence.)
proof/01_findings/S3/tests/*.dart 4 files, 40 tests Read all four in full. 25 tests mutation-tested to the R8 standard; 22 pass, 3 fail (S3R-F1).
proof/01_findings/S3/mutations/* 8 files Read all four patches and all four after.txt headers. R8 (a) and (b) are not recorded (S3R-F4); (c) and (d) are met.
proof/01_findings/S3/captures/android_audiomanager.txt 151 KB Verified S3's quotes at :4646 and :4663 are verbatim and correctly attributed. The quote is accurate; the inference drawn from it is not (S3R-F2).
flutter_local_notifications-22.1.0 pub cache Read lib/src/flutter_local_notifications_plugin.dart:112-200 to settle BLOCKER 2 independently. The ArgumentError is unconditional and precedes any platform resolution.
AOSP AudioService.java (android14-release) 13,268 Fetched, sha256-stamped and excerpted into captures/. Read setStreamVolume and all four helpers it gates on.

Artefacts produced by this refutation

Path What it is
proof/01_findings/S3_refute/01_suite_with_s3_tests.txt Baseline on my own copy — 163 passed, EXIT_CODE=0
proof/01_findings/S3_refute/02_banner_defect_test.txt The three new banner tests — 3 passed, EXIT_CODE=0
proof/01_findings/S3_refute/03_analyze.txt flutter analyze with everything present — No issues found!, EXIT_CODE=0
proof/01_findings/S3_refute/tests/s3r_banner_test.dart Control + two defect tests proving S3R-F3
proof/01_findings/S3_refute/mutations/mutation_harness.py [not published] The R8 harness: apply, whole-suite --reporter=json, parse failing set and result, revert, git status --porcelain
proof/01_findings/S3_refute/mutations/mutation_results.json Machine-readable verdict for all 26 runs
proof/01_findings/S3_refute/mutations/{A1..A11,A4b,V1..V6,B1..B5,H1..H3}.patch 26 distinct patches, one per run
proof/01_findings/S3_refute/mutations/raw_json/*.json 26 whole-suite reporter streams
proof/01_findings/S3_refute/captures/aosp_audioservice_setStreamVolume.txt AOSP AudioService excerpts, source URL, retrieval date, sha256 of the decoded file

Stream S4: finding and refutation

S4 — UI layerfindings/S4_ui.md · raw .md

S4 — UI layer

Subject: the app repository at 03a176e72ef0075eec86b8915cbe6e93042a3b9d. Scope: lib/ui/home.dart, tile.dart, modals.dart, header.dart, grid_layout.dart, theme.dart, logo.dart. lib/main.dart belongs to S14 and is only cited, never judged as a file.

Everything below was measured, not reasoned. The instruments are widget tests written for this audit against a working copy at a scratch working copy (the subject repo was never written to). They are stored, runnable, at proof/01_findings/S4/tests/ and every run is recorded through proof/run_and_record.sh [not published]:

Instrument Proof file What it measures
s4_layout_stress_test.dart proof/01_findings/S4/layout_stress.txt 55 surface×count configurations, name pathologies, running boards
s4_touch_targets_test.dart proof/01_findings/S4/touch_targets.txt hit rectangle of every interactive element, tile / header / both dialogs
s4_rebuild_cost_test.dart proof/01_findings/S4/rebuild_cost.txt widgets rebuilt per 150 ms tick, repaint-boundary audit, render-tree size
s4_contrast_test.dart proof/01_findings/S4/contrast.txt WCAG contrast of 47 foreground/background pairs + urgency-ramp luminance
s4_a11y_and_hit_test.dart proof/01_findings/S4/a11y_hit_test.txt is the clipped stop button tappable, semantics tree, text-scaling
s4_boot_hang_test.dart proof/01_findings/S4/boot_hang.txt what the board does when a boot dependency never answers
s4_tile_edge_cases_test.dart proof/01_findings/S4/tile_edge_cases.txt zero-duration timer, null remainingMs, 48 h ring, 40×30 tile
s4_screenshot_test.dart proof/01_findings/S4/shots/*.png seven rendered boards with the real bundled fonts
greps proof/01_findings/S4/greps.txt, grep_semantics_repaint.txt semantics, repaint boundaries, dead symbols, .timeout(
font cmap scan proof/01_findings/S4/font_glyph_coverage.txt which control glyphs the seven bundled TTFs actually contain

External source used for the touch-target threshold: 48 dp × 48 dp minimum, developer.android.com, "Make apps more accessible" — "we recommend that each interactive UI element have a focusable area, or touch target size, of at least 48dpx48dp" — retrieved 2026-08-04, capture at proof/03_market/captures/s4_android_accessibility_apps.txt line 80. The same page gives the text contrast thresholds used below (4.5:1 normal, 3:1 large — lines 69-70). Apple's own figure could not be captured: developer.apple.com/design/human-interface-guidelines/* never reaches network-idle through utilities/chrome.py (three attempts, 20 s / 60 s / 120 s, all Page.goto: Timeout), so no Apple number is quoted anywhere in this report. Missing artifact: a developer.apple.com HIG capture; the test that would add it is a chrome.py run with wait_until="domcontentloaded" instead of networkidle.

Severity counts: 0 BLOCKER, 4 HIGH, 11 MEDIUM, 3 LOW (18 entries; the last, S4-F18, records what was checked and found sound).


Findings

S4-F01 — The ±10 s / ✕ control row overflows its tile, and at dense boards the stop button is clipped away

  • Severity: HIGH
  • Location: lib/ui/tile.dart:600 (the Row), sized by lib/ui/tile.dart:573-598, hit padding lib/ui/tile.dart:607, :615, :623 (valid at 03a176e)
  • What is wrong: the control row is a Row(mainAxisSize: MainAxisSize.min) whose children are sized from a mix of tile-proportional values (minW = 26 * cw, i.e. 26 % of the tile width, for each of the ± buttons) and fixed pixel values that do not scale (the invisible hit paddings 7/2/2/2/0/7, 20 px in total, plus the floors minH = 42 / 38). Nothing constrains the sum. On a large tile the fixed part is negligible; from roughly 300 px of tile width downward it is not, and the row runs past its box. Flutter reports it as a layout error, and where the excess is larger than the tile's own 3 % padding the tile's ClipRRect (lib/ui/tile.dart:327) cuts the last child — which is the ✕ stop button. 19 of the 30 measured surface×count configurations clip at least one control; 19 of the 25 configurations in the running-board matrix raise at least one RenderFlex overflowed assertion.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: ✕ is how a cook kills a timer that is no longer wanted and how a ringing alarm gets cleared from the board. On a full service board it is the control most likely to be pressed in a hurry, and it is the one the layout throws away first. The operator presses where the button should be, nothing happens, and the tile keeps counting.
  • Proposed fix: 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 the fix: 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

  • Severity: HIGH
  • Location: lib/ui/tile.dart:393-415 (FlexibleFittedBox(fit: BoxFit.scaleDown)Text, base size fontSize: 14 * ch at :409) (valid at 03a176e)
  • What is wrong: the source comment at lib/ui/tile.dart:391-392 states the intent verbatim — "long names auto-shrink to stay fully readable (retour Serge 22/07 — remplace l'ellipsis du proto : un nom coupé ne sert à rien en cuisine)". BoxFit.scaleDown has no lower bound, so "fully readable" is not what happens: the name shrinks until it fits, however small that is. The editor itself allows 24 characters (maxLength: 24, lib/ui/modals.dart:251), so this is not an exotic input — it is the documented maximum a cook can type.
  • Evidence: 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].
  • Why it matters for a restaurant kitchen: the operator reads this board while walking past it. A 7.5 px name at two metres is a grey smudge; the cook has to stop, lean in, and identify the dish by its remaining time instead of its name. That is exactly the failure the ellipsis was replaced to avoid.
  • Proposed fix: 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 the fix: 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

  • Severity: HIGH
  • Location: lib/ui/tile.dart:431-435 (×N chip), :573-598 + :677-678 (± and ✕), lib/ui/header.dart:179-181 (all three header buttons), lib/ui/modals.dart:96-97, :124, :445-446, :509 (dialog controls) (valid at 03a176e)
  • What is wrong: the hit rectangles were measured, not estimated (they include the invisible hit padding the code adds). Against the 48 dp × 48 dp Android minimum, 16 of the 20 measured interactive element types fall short in at least one supported configuration, and four of them fall short in every configuration: the header's ⚙ (40.0 dp tall at best, 33.0 dp on a narrow surface), Edit/Done (38.0) and New (38.0), plus every chip, stepper and delete control in the two dialogs. The tile's ± and ✕ pass on roomy boards and fail from 30 tiles onward (e.g. ✕ at 40.8 × 49.2 on a 30-tile 1280×800 board — 7 dp short on width), and the ×N batch chip falls to 40.0 dp tall from four tiles onward.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the operator's finger is wet, gloved, or greasy, and the tablet is at arm's length on a wall. A 32 px preset pill and a 36 px step-remove ✕ are phone-at-a-desk sizes. Worse, the two smallest targets sit next to destructive actions: the step-remove ✕ (29.1 × 36.0) is 6 px from a number field, and the header's Edit (38 tall) is 14 px from New.
  • Proposed fix: 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 the fix: 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-F04 — The 150 ms heartbeat is gated behind three awaits with no timeout; if one never completes the board freezes silently

  • Severity: HIGH
  • Location: lib/ui/home.dart:136 (await sounds.init()), :148 (await _initSystemVolume()), :149 (await backstop.init()), :152 (backstop.sync(...)), :154 (the Timer.periodic) (valid at 03a176e)
  • What is wrong: _boot() creates the heartbeat only after three platform-dependent futures have completed. The try/catch at :135-140 catches a throw, not a hang. grep -rn "\.timeout(" lib/ returns exactly one site in the whole codebase, and it is in audio/voice.dart:168 — none of the three boot awaits has one. If any of them never completes, _ticker stays null forever: engine.tick() is never called, so no alarm fires in-app; backstop.sync() at :152 never runs, so no OS backstop notification is armed either; and setState is never called, so the board keeps showing the restored countdowns frozen at their boot values. Nothing on screen says anything is wrong, because the operator banner only reacts to Diag.fail, which requires a throw.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: this is the failure mode the whole product exists to prevent. A frozen board still shows plausible numbers; the cook trusts it, and the alarm never rings — not in the app and not through the OS backstop, because the backstop is armed after the audio await.
  • Proposed fix: 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 the fix: 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.
  • Verdict boundary: what is proven here is the shape — no timeout, ticker gated, no operator signal. What is not proven is the frequency on a real tablet: in flutter_test the audioplayers future cannot resolve at all, so the harness cannot distinguish "hangs on device" from "hangs only in the harness". Missing artifact: an instrumented run on a physical Android tablet with media.audio_flinger killed during app start; the test that would settle it is that run plus the journal line demarrage/audio sequence from lib/ui/home.dart:104,137.

S4-F05 — One tile's countdown rebuilds every tile, and the whole screen is a single repaint layer

  • Severity: MEDIUM
  • Location: lib/ui/home.dart:154-167 (the ticker's setState), :559 (now: DateTime.now() handed to the header every tick), :593-600 (the tile Stack) (valid at 03a176e)
  • What is wrong: the ticker calls setState(() {}) on _HomeScreenState, which rebuilds the entire subtree: header, logo, clock, three header buttons, the banner listener, the LayoutBuilder, and every TileView — including the tiles whose displayed content did not change. There is no RepaintBoundary anywhere in lib/, so the only boundaries in the tree are the two Flutter inserts above the page; every tile's pie repaint therefore dirties the same layer as the header and all other tiles. This runs 6.67 times a second, all day, on a wall-mounted tablet.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the screen is on for a 14-hour service on a cheap tablet that is often not on a charger and sits above a hot line. Constant full-tree rebuild plus full-screen repaint is the app's battery and thermal budget. Handed to S12 as measured input.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/ui/theme.dart:37-39 (anchors), :59-69 (fillFor) (valid at 03a176e)
  • What is wrong: the tile background encodes remaining time continuously from mint through amber to red. Computed from the real palette, the mint and amber ends have essentially the same relative luminance (0.4546 vs 0.5040 → 1.098:1), so the entire mint→amber half of the ramp carries no lightness signal at all — it is a pure hue change along the green→yellow axis, which is precisely the axis a red-green colour-deficient operator cannot see. Worse, luminance rises as urgency increases (0.4546 at full time → 0.5040 at the amber knee) before falling to 0.2918 at red, so a viewer reading lightness alone gets an inverted cue for two thirds of the countdown.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the pie is the two-metre signal; the digits are the arm's-length signal. For an operator with red-green colour deficiency the pie's first half conveys nothing, and the wedge/track edge is a 1.4:1 boundary in a room that may be lit at 200 lux or by a window at 20,000. The information is still available in the digits, which is why this is MEDIUM rather than HIGH — but the redundant channel the design is paying for does not work for everyone.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/ui/tile.dart:207 (C.muted idle countdown), :506+:536 (banner on C.amber), :544+:559 (edit badge), lib/ui/header.dart:184-185 (active Edit button), lib/ui/modals.dart:99+:110 (selected chip) (valid at 03a176e)
  • What is wrong: the palette's onAccent cream on C.amber gives 2.43:1, below the 3:1 floor for large text and far below the 4.5:1 for normal text. It is used for the chained-timer phase banner (the text that says which step is running), the EDIT badge, the header's active Edit state, and every selected chip in the editor. Separately, an idle tile's duration is drawn in C.muted on C.tileIdle at 2.67:1 — that is the number the cook reads to pick the right timer before starting it.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the amber band is what tells a cook that a chained dish has moved from Sear to Rest. It is read across the pass in a bright room. At 2.43:1 the phase name washes out at exactly the moment it matters, and the "2/3" step counter inside it (1.26:1) is effectively invisible.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: whole layer — grep for Semantics|semanticLabel|ExcludeSemantics|tooltip over lib/ returns nothing (valid at 03a176e)
  • What is wrong: every control is a bare GestureDetector, so nothing is exposed as a button; the only labels the platform sees are the literal glyph strings. A running tile announces 8:88 — the decorative "unlit segment" ghost text at lib/ui/tile.dart:485 — immediately before the real countdown, and its controls announce as +, 10, , 10, with no indication of what they do. The clock announces as two separate numbers (12, 38).
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: partially-sighted operators exist in kitchens, and both stores test with the platform screen reader. A tile that reads "8:88, 3:20, #2, plus, 10, minus, 10, ✕" is unusable, and the ghost digits make it worse than silence.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/main.dart:48 (owned by S14 — cited, not judged as a file), consumed by every text style in lib/ui/* (valid at 03a176e)
  • What is wrong: the wrapper discards the OS text-size setting for the whole app. Measured, it is a complete no-op at any scale factor, and it does prevent damage on the board: without it, a 2.0 scale factor doubles the number of layout overflows on a 12-tile running board.
  • Evidence: 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
  • Why it matters for a restaurant kitchen: on the board the argument holds — tile type is already proportional to the tile, so the OS setting would only break the layout that the auto-scale exists to manage, and an operator who needs bigger digits gets them by running fewer timers. In the dialogs the argument does not hold: the editor and Settings are ordinary scrolling forms with fixed 10.9–25.6 px type (lib/ui/modals.dart:48, :58, :256, :669, :684), they already scroll (SingleChildScrollView, lib/ui/modals.dart:26), and a partially-sighted operator has no way to make the 10.9 px field labels or the 12.8 px volume note readable. The blanket opt-out is a board decision applied to a form.
  • Proposed fix: 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 the fix: 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.
  • Store implication: neither store rejects an app for ignoring text scaling; both flag it in accessibility review. Recorded here so S11 can decide whether to state it in the listing.

S4-F10 — A failed save is reported to the log and to nobody else; the branch that would warn is unreachable

  • Severity: MEDIUM
  • Location: lib/ui/home.dart:443-450 (the failed-save path), :679-680 (the banner mapping) (valid at 03a176e)
  • What is wrong: when engine.saveDef(...) returns false the code writes Diag.fail('save-def', 'timer "…" introuvable — modification NON enregistree') without isCritical: true. Diag.fail only adds a scope to Diag.critical when isCritical is set (lib/diagnostics.dart:32-34), and the operator banner renders from Diag.critical (lib/ui/home.dart:670-671). So the scope.startsWith('save') branch at :679 — which exists precisely to show "⚠️ Saving failed — your changes may be lost" — cannot fire from this call site. The code's own comment two lines above says the opposite: "the operator must not be left thinking it was". The dialog simply closes and the edit is gone.
  • Evidence: 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}; }
  • Why it matters for a restaurant kitchen: a cook re-times a dish before service, presses Save, sees the dialog close, and walks away. The timer still holds the old duration. The banner exists for exactly this and never lights.
  • Proposed fix: 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 the fix: 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.
  • Reachability, stated plainly: with today's single-screen, modal-dialog architecture nothing mutates engine.timers while the editor is open, so saveDef returning false is not reachable in normal operation — this is a dead branch that is also wrong. Both facts are the finding; the one-word fix costs nothing and removes the trap for whoever makes the editor non-modal.

S4-F11 — Every icon in the product is a text glyph that no bundled font contains

  • Severity: MEDIUM
  • Location: lib/ui/tile.dart:619 (), :552 (✎ EDIT), lib/ui/header.dart:97 (), lib/ui/home.dart:708 (), lib/ui/modals.dart:348 (🗑), :624, :626 (flags), lib/i18n.dart:41-43 and :89-91 (✎ Edit, ✓ Done, + New), :70-79 and :117-124 (the ⚠️ banner prefixes) (valid at 03a176e)
  • What is wrong: the app ships seven TTFs and pins fontFamily explicitly on nearly every Text. None of the seven contains any of the symbol code points the UI uses. Rendering therefore depends entirely on the host's fallback font — including for , the stop control.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the target hardware is a cheap Android tablet, and OEM builds of that class routinely ship a trimmed font set. If Noto Sans Symbols is absent, the operator's stop button is a hollow rectangle and the Settings button is another one. There is nothing in the app to fall back to.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/ui/home.dart:690-702 (banner box and text style), :565 (position in the column) (valid at 03a176e)
  • What is wrong: the critical banner — the single mechanism telling a kitchen that voice, sound, wakelock, saving or the OS backstop is down — is rendered at fontSize: 13.5 in a 19 px-tall strip, while the countdowns beside it are 30–140 px. It has no dismiss, and the one scope raised by this layer's own boot — audio-init (lib/ui/home.dart:139) — is never passed to Diag.clearCritical anywhere in lib/ (the six clear sites are audio-play, voice-init, backstop-init, backstop-schedule, backstop-exact, save-$key). A tablet whose audio initialised badly but plays fine thereafter therefore carries "⚠️ Sound is not working on this tablet" for the rest of the session, which trains the kitchen to ignore the banner. When several scopes fail the messages are joined into one paragraph that wraps and pushes the grid down.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: "the alarm sound is dead" is the most important sentence this app can say, and it is set smaller than every other word on screen, at the top edge, in a room where nobody is reading the top edge.
  • Proposed fix: 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 the fix: 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-F13 — 1,541 lines of interaction model are at 0.00 % coverage, and four defects were sitting in them

  • Severity: MEDIUM
  • Location: lib/ui/home.dart (722 lines), lib/ui/tile.dart (819 lines), lib/ui/header.dart (215), lib/ui/theme.dart (82), lib/ui/logo.dart (18) — all 0.00 % in proof/00_baseline/SUMMARY.md (valid at 03a176e)
  • What is wrong: no test imports home.dart, tile.dart, header.dart or logo.dart (code map §2.15-2.18), and theme.dart is only read as compile-time constants, so fillFor, fmtTime and fmtUp never execute. Everything the operator actually touches — tap/double-tap disambiguation, the ±10 s handlers, drag-to-reorder, edit mode, the batch chip, the banner, the pie painter, the six animation controllers, the header's five breakpoints — is unexercised. This audit's instruments are the first code ever to render a TileView, and they found four defects on the first run (F01, F02, F03, F05). The 0 % is not a metric problem; it is the reason those four shipped.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: every future change to the tile is made blind. The control row can be re-tiered, the pie can be re-anchored, the tap window can be re-tuned, and nothing in CI will notice a regression that silences a timer.
  • Proposed fix: 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.
  • How to prove the fix: 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.

S4-F14 — The grid hit-test is written twice, and the tick period is declared in the engine and hard-coded in the UI

  • Severity: MEDIUM
  • Location: lib/ui/home.dart:515-521 vs lib/ui/home.dart:609-613; lib/engine/engine.dart:32 vs lib/ui/home.dart:154 (valid at 03a176e)
  • What is wrong: (a) _panUpdate re-implements the pointer→tile-index computation that _tileIndexAt already performs, character for character except for the variable names: dart // _panUpdate, home.dart:515-521 final col = ((localPos.dx - gridOrigin.dx) / (lay.tileW + lay.gap)).floor(); final row = ((localPos.dy - gridOrigin.dy) / (lay.rowH + lay.gap)).floor(); if (col >= 0 && col < lay.cols && row >= 0) { final idx = row * lay.cols + col; … // _tileIndexAt, home.dart:609-613 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; Drag pick-up and drop targeting can therefore drift apart with one edit. (b) Engine.tickMs = 150 is declared and used nowhere: grep -rn "tickMs" lib/ test/ returns the declaration only, while the heartbeat writes the literal 150 at lib/ui/home.dart:154. The sibling constant Engine.dblMs is imported correctly at :387, which shows the intended pattern.
  • Evidence: proof/01_findings/S4/greps.txt: ### grep -rn tickMs lib/ test/ lib/engine/engine.dart:32: static const int tickMs = 150; exit=0
  • Why it matters for a restaurant kitchen: drag-to-reorder is how the board is arranged to match the line. Two copies of the same geometry is how "the tile I dropped on" stops being "the tile it moved to".
  • Proposed fix: 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).
  • How to prove the fix: 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/.

S4-F15 — The UI layer's geometry lives in ~120 inline literals with no constants file

  • Severity: MEDIUM
  • Location: lib/ui/tile.dart, lib/ui/header.dart, lib/ui/modals.dart, lib/ui/home.dart (valid at 03a176e)
  • What is wrong: theme.dart owns colours, fonts, tones and duration presets, and stops there. Every dimension, ratio, breakpoint and animation duration is written at its use site, several of them more than once and in more than one file. The repeated ones (this is the subset that matters — a value used twice is a value that will be changed once):
Value Meaning Sites
20 card corner radius tile.dart:218, tile.dart:798 (20 - inset), modals.dart:19
999 pill radius tile.dart:545, modals.dart:100
560 clock breakpoint / dialog max width header.dart:37, modals.dart:24
20.8 clock size / editor field size header.dart:137, :138, modals.dart:256, :341
12 control corner radius header.dart:193, modals.dart:72, :75, :100, :464, :555
150 tick period engine.dart:32 (unused), home.dart:154
0.85 grid aspect cap / tile spawn scale grid_layout.dart:24, tile.dart:350
0.15 volume floor / urgency knee / spawn scale delta alarm_volume.dart:28, theme.dart:67, tile.dart:350
7 / 2 invisible hit padding tile.dart:431, :607, :615, :623
42 / 38 touch-target floors tile.dart:577, :585
240 / 150 tile width tiers tile.dart:573, :581
470/820/800/960/560 header degradation ladder header.dart:33-37
#5CC79A / #EC6A6A urgency end anchors, written twice in two notations theme.dart:37+:65, theme.dart:39+:68

The last row is the sharpest: fillFor returns const Color(0xFF5CC79A) for p >= 1 and const Color(0xFFEC6A6A) for p < 0.15, which are the same colours as C.fMint and C.fRed two dozen lines above, expressed differently. Retuning the ramp through the anchors leaves the two endpoints behind. - Evidence: 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. - Why it matters for a restaurant kitchen: indirectly but concretely — F01 exists because the fixed 7/2 hit paddings and the proportional 26 * cw widths were tuned in different places and never added up in one. - Proposed fix: 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 the fix: 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.


S4-F16 — Dead symbols in the UI layer (handed to S6, not tabulated here)

  • Severity: LOW
  • Location: lib/ui/theme.dart:23 (C.mint), :29 (C.logoInk), lib/ui/grid_layout.dart:42+:94 (GridLayout.pad), lib/engine/engine.dart:32 (Engine.tickMs, also in F14) (valid at 03a176e)
  • What is wrong: four public symbols in or feeding this layer have no production consumer. C.mint and C.logoInk are colours nothing references — note that logoInk exists precisely for the header wordmark, which instead hard-codes Colors.white (lib/ui/header.dart:73), so the token and its intended use site have silently diverged. GridLayout.pad is computed, clamped and exposed but read only by test/grid_layout_test.dart; the board derives its margin implicitly from gridW/gridH centring at lib/ui/home.dart:573-574.
  • Evidence: 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)
  • Why it matters for a restaurant kitchen: it does not, directly. It matters because the next person tuning the palette will assume C.mint is live and change the wrong token.
  • Proposed fix: 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.
  • How to prove the fix: covered by S6's repo-wide dead-code sweep; this stream hands over the four symbols above rather than duplicating that table.

S4-F17 — A timer left ringing overnight prints a five-digit minute counter that the tile was never sized for

  • Severity: LOW
  • Location: lib/ui/theme.dart:77-82 (fmtTime / fmtUp), consumed at lib/ui/tile.dart:200 (valid at 03a176e)
  • What is wrong: fmtTime formats as m:ss with no hour rollover, and the ringing tile shows elapsed time since rangAt. An alarm nobody cleared before close shows +720:00 after 12 h and +2880:00 after 48 h — three characters wider than the layout's widest expected string, which drives the FittedBox-free _timeWrap (lib/ui/tile.dart:473-491) to render a string the tile cannot hold.
  • Evidence: 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.)
  • Why it matters for a restaurant kitchen: low impact — a timer ringing for twelve hours is already a lost cause — but the same formatter serves the countdown, and a chained dish with a 3-hour step (min is capped at 180 in the editor, lib/ui/modals.dart:428) prints 180:00, five characters, on every tile of a dense board.
  • Proposed fix: 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 the fix: 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.

S4-F18 — What was checked and found sound

  • Severity: LOW (recorded so the coordinator can see the negative space)
  • lib/ui/grid_layout.dart is correct under every stress applied. Across 55 surface×count configurations — 1 to 100 timers, 320×480 to 1366×1024, both orientations, half-height and half-width split-screen frames — the solved grid never produced a negative dimension, never overflowed its frame, and always allocated at least n cells (proof/01_findings/S4/layout_stress.txt, errors=none on every STRESS row). The column choice degrades sensibly (12 timers → 4×3 landscape, 3×4 portrait; 100 timers → 13×8 landscape). Its 100 % coverage is real coverage.
  • lib/ui/header.dart's degradation ladder works as documented. Words drop at ≤ 800 px (✎ EDIT), the descriptor disappears above/below 960 px, the clock disappears at ≤ 560 px — all confirmed by the HEADER rows in proof/01_findings/S4/touch_targets.txt.
  • The tile survives degenerate data. A zero-duration running timer, a zero-length chain step, a paused entry with remainingMs == null, and a 40 × 30 px tile all render without an exception (proof/01_findings/S4/tile_edge_cases.txt) — the only errors reported are the control-row overflow of F01.
  • The idle board never overflows. Every STRESS configuration with no timer running is clean; the overflow in F01 appears only when the ±10 s / ✕ row exists, i.e. only during service.
  • The palette's core pairs are strong: 12.23:1 for tile digits, 17.38:1 for the header wordmark, 15.37:1 for button ink, and 5.31–8.60:1 for the countdown over the whole urgency ramp (proof/01_findings/S4/contrast.txt).

Layout-stress results

Board rendered through the real HomeScreen; tile is the measured tile box in logical pixels; nameFont/timeFont are the declared sizes (they are what a short name and the countdown actually render at — the FittedBox only shrinks longer names, see F02). Full matrix, 55 rows: proof/01_findings/S4/layout_stress.txt.

Surface n=1 n=2 n=12 n=30 n=100
1280×800 landscape (10″) 1×1, 1174×634, digits 139 2×1, 602×512, 113 4×3, 305×230, 51 6×5, 204×138, 30 13×8, 94×80, 18
800×1280 portrait (10″) 1×1, 733×623, 137 1×2, 733×565, 124 3×4, 253×215, 47 4×8, 190×144, 32 8×13, 95×81, 18
1366×1024 landscape (12.9″) 1×1, 1253×851, 187 2×1, 642×546, 120 4×3, 325×276, 61 6×5, 218×182, 40 10×10, 131×91, 20
1024×1366 portrait (12.9″) 1×1, 939×798, 176 1×2, 939×594, 131 3×4, 324×275, 61 5×6, 195×166, 37 8×13, 122×95, 21
600×960 portrait (7″) 1×1, 550×467, 103 1×2, 550×417, 92 3×4, 189×161, 35 4×8, 143×106, 23 8×13, 71×61, 13
960×600 landscape (7″) 1×1, 880×460, 101 2×1, 451×384, 84 4×3, 228×167, 37 6×5, 153×100, 22 13×8, 71×60, 13
360×640 phone portrait 1×1, 330×280, 62 1×2, 330×272, 60 2×6, 169×90, 20 4×8, 85×69, 15 7×15, 49×37, 8
640×360 phone landscape 1×1, 587×250, 55 2×1, 301×256, 56 4×3, 152×92, 20 8×4, 76×65, 14 13×8, 47×35, 8
1280×400 split-screen 1×1, 1174×234, 51 2×1, 602×285, 63 6×2, 204×157, 35 10×3, 123×104, 23 17×6, 72×53, 12
640×800 split-screen 1×1, 587×499, 110 1×2, 587×334, 73 3×4, 202×172, 38 5×6, 122×104, 23 8×13, 76×54, 12
320×480 smallest 1×1, 293×249, 55 1×2, 293×195, 43 3×4, 101×86, 19 5×6, 61×52, 11 8×13, 38×31, 7

(cells: cols×rows, tileW×tileH, countdown font px)

Idle boards: no overflow, no clipping, in all 55 configurations. Running boards (control row present): 19 of 25 configurations raise at least one RenderFlex overflowed; 19 of the 30 configurations in the touch matrix clip at least one control (F01).

Pathological names, effective rendered size after the FittedBox (F02):

Surface / n 5-char "Fries" 24-char (editor max) 60-char unbroken 120-char
1280×800, n=1 88.8 px 43.4 px 17.4 px 9.1 px
1280×800, n=12 32.2 px 11.3 px 4.5 px 2.4 px
1280×800, n=30 19.3 px 7.5 px 3.0 px 1.6 px
600×960, n=12 22.5 px 7.0 px 2.8 px 1.5 px
600×960, n=30 14.8 px 5.3 px 2.1 px 1.1 px
320×480, n=1 34.9 px 10.8 px 4.3 px 2.3 px

Emoji in a name are safe (a 12-char name with three emoji renders at 27.1 px on a 12-tile board).


Touch-target measurements

Hit rectangles in logical pixels (= dp on Android), measured on the rendered tree, including invisible hit padding. Threshold: 48 × 48 dp (developer.android.com, capture proof/03_market/captures/s4_android_accessibility_apps.txt:80, retrieved 2026-08-04). Full data: proof/01_findings/S4/touch_targets.txt.

Element Source Measured (best case) Measured (worst supported case) Verdict
Tile body (start / pause / stop-ring) tile.dart:372 1174×634 49×37 (100 timers, 360×640) passes to ~30 timers
Tile +10 tile.dart:601-608 368×166 21.7×13.2 fails from n≈30
Tile −10 tile.dart:610-616 363×166 21.7×13.2 fails from n≈30
Tile stop tile.dart:618-625 198×139 16.9×13.2, 4.6 px visible fails from n≈30, clipped
Tile #N batch chip tile.dart:427-435 175×90 (n=1) 55.5×40.0 fails from n≈4 on height
Header header.dart:96-102 58.2×40.0 35.3×33.0 (≤470 px) fails always
Header Edit/Done header.dart:104-111 143.6×38.0 33.3×31.0 fails always
Header New header.dart:113-120 126.0×38.0 33.3×31.0 fails always
Editor ▲ minute/second modals.dart:442-455 52.0×40.0 52.0×40.0 fails always
Editor ▼ minute/second modals.dart:442-455 52.0×40.0 52.0×40.0 fails always
Editor step-remove modals.dart:504-515 29.1×36.0 29.1×36.0 fails always
Editor duration preset pill modals.dart:289-305, :96 86.4×32.0 86.4×32.0 fails always
Editor type chip (Single / Multi-step) modals.dart:262-282, :97 250.0×42.0 250.0×42.0 fails always
Editor ringtone chip (×12) modals.dart:321-331 112.5×42.0 112.5×42.0 fails always
Editor 🗑 delete modals.dart:348-351, :124 42.6×58.0 42.6×58.0 fails on width
Editor + Add step modals.dart:548-567 508.0×43.0 508.0×43.0 fails on height
Settings language button (×2) modals.dart:729-745 250.0×42.0 250.0×42.0 fails always
Editor Cancel / Save modals.dart:354-358 222.7×58.0 222.7×58.0 passes
Settings Close / Send the log modals.dart:678, :691 508.0×58.0 508.0×58.0 passes
Settings volume slider modals.dart:655-664 508.0×48.0 508.0×48.0 passes

16 of 20 interactive element types measure below 48 dp in at least one supported configuration; 12 of them fail in every configuration (the header's three buttons, both duration steppers, the step-remove ✕, the preset pills, the type chips, the twelve ringtone chips, the delete button, "Add step", and the two language buttons).


Contrast ratios

Computed from the live palette (the test imports lib/ui/theme.dart; no hex value was transcribed by hand). Thresholds 4.5:1 normal text / 3:1 large text, per the same Android accessibility page (s4_android_accessibility_apps.txt:69-70). All 47 pairs: proof/01_findings/S4/contrast.txt.

Surface Foreground Background Ratio Verdict
Idle tile — name & digits C.text #1D211E C.tileIdle #E7DED0 12.23:1 pass
Idle tile — duration readout C.muted #828A80 C.tileIdle 2.67:1 fail
Running tile — digits over ramp p=1.00 C.text #5CC79A 7.83:1 pass
Running tile — digits over ramp p=0.35 C.text #EDB24E 8.60:1 pass
Running tile — digits over ramp p=0.15 C.text #EC6A6A 5.31:1 pass
Running tile — digits over elapsed track C.text C.track #E7DED0 12.23:1 pass
Paused tile C.pausedText C.pausedFill 5.04:1 pass
Ringing tile — dish name C.ringName C.ringInnerTop 7.92:1 pass
Ringing tile — digits C.red #E03131 C.ringInnerTop 3.88:1 large only
Ringing tile — digits (lower half) C.red C.ringInnerBottom 3.54:1 large only
Chain banner — phase name C.onAccent C.amber #DD9207 2.43:1 fail
Chain banner — step counter "2/3" white 24 % C.amber 1.26:1 fail
Edit-mode badge "✎ EDIT" C.onAccent C.amber 2.43:1 fail
Edit-mode veil — tile text under veil #9A9991 #EFE8DC 2.35:1 fail (intentional wash)
±10 s button ink C.text C.panel2 15.37:1 pass
Stop button ink C.red #FEF0F0 4.07:1 large only
Header wordmark / clock white C.headerBg 17.38:1 pass
Header descriptor C.headerInk 58 % C.headerBg 5.57:1 pass
Header button — idle C.text C.panel2 15.37:1 pass
Header button — primary (New) C.onAccent C.ember 3.26:1 large only
Header button — active (Edit on) C.onAccent C.amber 2.43:1 fail
Critical banner white #B3452B 5.52:1 pass
Empty-board title C.muted C.bg 3.11:1 large only
Modal title C.text C.panel2 15.37:1 pass
Modal field label C.muted C.panel2 3.36:1 large only (10.9 px → fail)
Modal input hint C.muted C.bg 3.11:1 large only (20.8 px → pass)
Modal selected chip C.onAccent C.amber 2.43:1 fail
Modal selected pill C.onAccent C.ember 3.26:1 large only
Modal step index C.ember C.bg 3.00:1 fail at 14.4 px
Modal footnotes (12.8 / 11.5 px) C.muted C.panel2 3.36:1 fail at that size

Urgency ramp separability (F06): mint↔amber 1.098:1, amber↔red 1.621:1, mint↔red 1.476:1; luminance rises from 0.4546 (p=1.00) to 0.5040 (p=0.35) before falling to 0.2918.


Widget tests Phase 4 must add

Written against the harness at proof/01_findings/S4/tests/s4_harness.dart (stubAllChannels, storeWith, pumpHome), which boots a real HomeScreen headlessly. Each name is the testWidgets description; each is red at 03a176e for the reason given.

test/tile_layout_test.dart 1. tile_controls_never_overflow — 30 (surface, n) pairs, all running; assert tester.takeException() is null and each control's rect is inside its tile rect. Red: 19/30 clip. (F01) 2. stop_button_stops_the_timer_at_every_density — for n ∈ {2, 12, 30, 100}, tap the ✕ centre and assert the tile loses its control row. Red at n=100. (F01) 3. dish_name_never_renders_below_floor — 5/24/60/120-character names × 8 configurations; assert declaredFont × fittedScale >= 9.0. Red: 2.11 px worst case. (F02) 4. every_interactive_element_meets_48dp — walk the board tree for gesture-accepting widgets and assert 48×48. Red: 16 element types. (F03) 5. tile_renders_degenerate_data — zero durationSec, zero-length step, null remainingMs, 40×30 box; assert no exception and a plausible time string. Green now — a regression guard, and the only test that will ever execute those branches. 6. ringing_counter_stays_within_the_tile — a timer ringing for 48 h; assert the rendered string is ≤ 7 characters and fits. Red: +2880:00. (F17)

test/home_board_test.dart 7. one_running_timer_rebuilds_only_its_own_tiledebugOnRebuildDirtyWidget counter over one tick on a 30-tile board; assert TileView rebuilds == 1. Red: 30. (F05) 8. idle_board_does_not_rebuild — 12 idle timers, one tick, assert 0 rebuilds. Red: 221 widgets. (F05) 9. each_tile_is_its_own_repaint_boundary — walk the render tree, assert one RenderRepaintBoundary per TileView. Red: 2 boundaries total, both above the board. (F05) 10. heartbeat_starts_even_when_audio_never_answers — audio channel wired to a never-completing Completer; assert ≥ 15 rebuilds over 20 ticks and a visible critical banner. Red: 0 and none. (F04) 11. failed_save_raises_the_operator_banner — force saveDef to return false; assert the saveFail string is on screen. Red: silent. (F10) 12. critical_banner_is_legible_and_clears — assert banner text height ≥ 20 px and that a successful ringtone removes audio-init from Diag.critical. Red: 13.5 px, permanent. (F12) 13. drag_pickup_and_drop_agree — 200 sampled pointer positions; _tileIndexAt vs the drop target. Red under the mutation patch that changes one divisor in _panUpdate. (F14) 14. single_tap_pauses_double_tap_resets — the 260 ms window at home.dart:387; assert one tap after 300 ms pauses and two taps inside 260 ms reset. No test covers the app's primary gesture today. 15. edit_mode_reorders_and_persists — enter edit mode, drag tile 3 onto tile 1, assert engine.timers order and that store.saveDefs was called. 16. board_ignores_os_text_scaling / dialogs_honour_os_text_scaling — the pair in F09.

test/header_test.dart 17. header_degradation_ladder — at widths 1000/900/810/790/560/460, assert descriptor, word labels and clock presence exactly as the ladder comment at header.dart:2-4 claims. No coverage today. 18. clock_colon_blinks_once_per_second — assert opacity 1 at millisecond < 500 and 0 above, and that disableAnimations pins it on. No coverage today.

test/theme_test.dart (82 lines, currently 0 %) 19. urgency_ramp_is_monotonic_and_separable — F06's assertions. 20. palette_pairs_meet_wcag — F07's assertions, as a permanent gate on palette edits. 21. fmt_time_handles_hours — F17's assertions, plus fmtTime(-5) == '0:00' and the rounding boundary (fmtTime(59.6) == '1:00', already true).

test/a11y_test.dart 22. board_is_screen_reader_usable — F08's assertions. 23. no_control_relies_on_a_fallback_font — F11's assertion.

Source-hygiene additions (extend test/source_hygiene_test.dart, which already scans lib/ as text) 24. no_repeated_magic_numbers_in_ui — F15's list. 25. ui_uses_engine_tick_constant — no Duration(milliseconds: 150) literal in lib/ui/. (F14)

Per R8, tests 1-4, 7-13 and 19-23 must each be shown red-then-green with the mutation patch stored beside the two outputs; tests 5, 14, 15, 17, 18 are new-coverage tests and must be proven real by mutating the production code they claim to cover.


Coverage manifest

Every file below was read end to end at 03a176e72ef0075eec86b8915cbe6e93042a3b9d.

File Lines Baseline coverage What was checked
lib/ui/home.dart 722 0.00 % Boot sequence and the three un-timed awaits (F04); ticker rebuild scope measured at n=1/2/12/30/100 and on an idle board (F05); repaint-boundary audit of the whole tree (F05); _criticalBanner scope mapping against every Diag.fail / Diag.clearCritical call site in lib/, banner geometry and style measured (F12), unreachable save branch (F10); grid placement driven through 55 surface×count configurations (stress table); _panUpdate vs _tileIndexAt duplication (F14); _empty() rendered; magic numbers catalogued (F15); semantics of the whole board dumped (F08); text-scaling behaviour measured with and without the wrapper (F09)
lib/ui/tile.dart 819 0.00 % Control-row geometry at all three width tiers, overflow and clipping measured in 30 configurations with the offending RenderFlex identified by line (F01); name FittedBox scale measured for 5 name classes × 9 configurations (F02); every hit rectangle measured (F03); pie colours over the whole ramp fed to the contrast computation (F06, F07); degenerate data (zero duration, zero step, null remainingMs, 40×30 tile) rendered (F18); ringing counter format at 1/12/48 h (F17); glyph coverage of , against the bundled fonts (F11); ghost-digit semantics (F08); six animation controllers' create/dispose pairs confirmed against the code map; magic numbers catalogued (F15)
lib/ui/modals.dart 746 65.33 % Every interactive element in both dialogs measured in single and chain mode at two surfaces (F03); all modal colour pairs computed (F07); glyphs 🗑, , , , flags checked against the bundled fonts (F11); fixed type sizes assessed against the text-scaling opt-out (F09); duplicated _inputDeco().copyWith blocks confirmed from the code map §3.8 and handed to S6; magic numbers catalogued (F15). Not re-audited: the editor's save/clamp logic already covered by announcement_test.dart and editor_layout_test.dart
lib/ui/header.dart 215 0.00 % Degradation ladder verified at 7 widths (F18); all three button hit rectangles measured at every width (F03); wordmark/clock/descriptor/button contrast computed (F07); Colors.white vs the orphaned C.logoInk (F16); , , , glyph coverage (F11); clock rebuild traced to the board's 150 ms setState (F05); breakpoint literals catalogued (F15)
lib/ui/grid_layout.dart 109 100.00 % Solver exercised through the real board at 11 surfaces × 5 counts including both orientations, split-screen frames and 320×480 — no negative geometry, no frame overflow, cell count always ≥ n (F18); pad field consumer search (F16); maxAspect duplication with tile.dart:350 (F15). No defect found in this file
lib/ui/theme.dart 82 0.00 % All 23 colour tokens and both font families' usage traced; 47 contrast pairs computed from the live values (F07); urgency ramp luminance profiled across p=1.00→0.00 (F06); duplicated anchor literals at :65/:68 vs :37/:39 (F15); C.mint, C.logoInk consumer search (F16); fmtTime/fmtUp executed at −5, 0, 59.4, 59.6, 3600, 36000 s (F17)
lib/ui/logo.dart 18 0.00 % Read in full; single Image.asset('assets/logo/mark_white.png') with filterQuality: medium, asset confirmed present by the code map §3.5. Rebuilds once per 150 ms tick as part of the header (F05) — that is its only finding, and it is folded into F05. No defect of its own: no state, no error path, no measurable target
Total 2,711
S4 refutation — UI layeragent_reports/S4_refute.md · raw .md

S4 refutation — UI layer

Refuter for stream S4, fresh context, governed by R5. Subject read-only at 03a176e72ef0075eec86b8915cbe6e93042a3b9d. All experiments ran on a copy at a scratch working copy with CADENCE_REPO set explicitly, so every proof header stamps the copy's HEAD (03a176e…) and the copy's own dirt, not the workspace root's.

Instruments I wrote are stored, runnable, at proof/01_findings/S4_refute/tests/; every run is recorded through proof/run_and_record.sh [not published] into proof/01_findings/S4_refute/.

Instrument Proof file What it settles
s4r_font_effect_test.dart font_effect_matrix.txt the same 30-config matrix run twice — test font vs the real bundled fonts
s4r_realfont_sweep_test.dart realfont_sweep.txt text-width inflation factor; 38-config stop-tap sweep with real fonts
s4r_forensics_test.dart forensics_stop_button.txt where a tap in the clipped region actually lands, what the timer then does, whether a ringing alarm can be silenced
s4r_touch_realfont_test.dart touch_targets_realfont.txt every hit rectangle re-measured with the real fonts
s4r_legibility_perf_test.dart legibility_and_frame_cost.txt, frame_cost.txt name size on glass by tile count; wall-clock cost of one heartbeat frame
s4r_missed_test.dart missed_findings.txt chip top-clipping, drawn-vs-tappable width, ±10 hit-rect overlap
s4r_web_probe.py + release web build release_web_render_n100.txt, release_web_stop_click_n100.txt, shots/web_release_n100_*.png what a release build actually draws, and whether the stop button works in it
re-runs of S4's own instruments repro_touch_and_stress.txt, repro_a11y_hit_test.txt, repro_contrast_boot_edge.txt, greps_recheck.txt reproduction of S4's numbers

Verdict: 2 findings REFUTED (S4-F01, S4-F02), 2 CONFIRMED with a corrected severity or a corrected interpretation (S4-F03, S4-F05), 14 CONFIRMED as written. 2 findings contributed. S4's "0 BLOCKER" is correct and I can now prove why.


1. Reproduction first — S4's numbers are reproducible, and wrong

Every headline number reproduces bit-for-bit on my copy (repro_touch_and_stress.txt, repro_a11y_hit_test.txt):

RUN rows total: 25   RUN rows with overflow: 19   RUN rows clean: 6
TOUCH rows: 30       rows with the stop button clipped: 19
HITTEST n=100 btn=24.7x27.5 visibleWidth=4.6 tapDelivered=true stopWorked=false
REBUILD n=30 runningTimers=1 rebuiltWidgets=495 … n=100 … 1475 … IDLE … 221
TREE n=30 elements=3305 renderObjects=2059 repaintBoundaries=2 customPaints=30

So 19/25, 19/30, 495, 1475, 221, 2 boundaries and the 4.6 px remnant are all real outputs of the instruments. The instruments are the problem.

The defect in the instrument

flutter_test renders text with a fallback test font in which every glyph is one em wide. s4_harness.dart never loads assets/fonts/*.ttf, so s4_layout_stress_test.dart, s4_touch_targets_test.dart and s4_a11y_and_hit_test.dart measured every string in the control row at test-font width. Only s4_screenshot_test.dart loads the real fonts (:21-43).

Measured inflation (realfont_sweep.txt):

TEXTW "10"     fontFamily=Chivo Mono 13px testFont=26.00 bundledFont=15.60 inflation=1.67x
TEXTW "+"      fontFamily=Chivo Mono 13px testFont=13.00 bundledFont= 7.80 inflation=1.67x
TEXTW "✕"      fontFamily=Chivo Mono 13px testFont=13.00 bundledFont= 7.80 inflation=1.67x
TEXTW "DISH 0" fontFamily=Chivo Mono 13px testFont=78.00 bundledFont=46.80 inflation=1.67x

The control row is nothing but those strings plus padding, so a 1.67× error on every string is a 1.67× error on the row width — which is exactly the quantity F01 is about.

The same matrix, both ways (font_effect_matrix.txt)

FONTS[testfont] SUMMARY configs=30 stopButtonClipped=19 configsWithOverflowAssertion=21
FONTS[realfont] SUMMARY configs=30 stopButtonClipped=5  configsWithOverflowAssertion=9

With the fonts the app actually ships, on the four tablet surfaces at every count up to 30, the stop button is inside the tile with 12 to 25 px to spare and no overflow assertion fires at all:

FONTS[realfont] 1280x800  n=12  tile=305.0x230.0 stop=49.6x56.0 pastTileEdge=-25.1 flutterErrors=0
FONTS[realfont] 1280x800  n=30  tile=204.0x138.0 stop=36.0x49.2 pastTileEdge=-19.8 flutterErrors=0
FONTS[realfont] 1366x1024 n=30  tile=218.0x182.0 stop=38.9x52.0 pastTileEdge=-17.3 flutterErrors=0
FONTS[realfont] 1280x800  n=100 tile= 94.0x 80.0 stop=22.1x27.5 pastTileEdge=  4.3 flutterErrors=200

The five clipped configurations are 1280×800 n=100 (4.3 px), 960×600 n=100 (9.9 px), 600×960 n=100 (10.3 px), 360×640 n=30 (5.1 px) and 360×640 n=100 (13.5 px). Nothing on a tablet below 100 tiles.

S4's own evidence already contradicted its own number and was not read: its real-font screenshot proof/01_findings/S4/shots/board_1280x800_n12_running.png [not published] shows a clean 12-tile board with the full ✕ button drawn inside the tile and no overflow stripe anywhere, while its font-less instrument reported 12 RenderFlex overflowed errors for that identical configuration.


2. What a RELEASE build does — settled empirically, not by argument

RenderFlex overflowed is raised from an assert, and the striped indicator is painted inside an assert(() { … }()) block; Row's clipBehavior defaults to Clip.none, so the Row never clips its own children in any mode. The only clip that survives into release is the tile's ClipRRect (lib/ui/tile.dart:327). Rather than argue that, I built and ran the thing.

flutter build web --release (AOT, asserts compiled out), served locally, driven through utilities.chrome.browser_launch at viewport 1280×800, deviceScaleFactor 1, seeded with 100 running timers — the exact configuration of the headline claim. Proof: release_web_render_n100.txt, screenshot shots/web_release_n100_before.png, magnified shots/web_release_n100_zoom_tiles.png.

Result:

  • No overflow stripes, no console error. The console carried eleven messages, all plugin MissingPluginExceptions and WebGL notices; not one RenderFlex.
  • The ✕ stop button is drawn in full, inside the tile, on all 100 tiles. The magnified crop shows + 10 | − 10 | ✕ complete on every tile.
  • The "no ✕ visible" in S4's own board_1280x800_n100_running.png is the debug overflow indicator painted over the control row, not the clip. In release that paint does not happen.

Then I clicked it. release_web_stop_click_n100.txt, click at (97, 178) — the ✕ of tile 0:

  • before: DISH 0 green, running, 59:54, control row present;
  • after: DISH 0 idle beige, 5:00, control row gone. (shots/web_release_n100_after_click.png.)

The stop button works, in a release build, at 100 timers on a 1280×800 board.


3. Does the tap fail? Yes — in two configurations out of 38, and it does not do nothing

forensics_stop_button.txt reads the run entry off the rendered TileView instead of guessing from the presence of a glyph, and dumps the actual hit-test path.

With the test font (S4's condition), the failure is real but rare and mis-described:

STOPTAP n=100 tile=94.0x80.0 btn=24.7x27.5 visible=4.6 pathHitsStopButton=false
              pathHitsTileBody=true status before=running after1pump=running after400ms=paused
SWEEP 1280x800 … 19 counts: tap fails at n=36 and n=100 only; every other count STOPPED
SWEEP 1024x768 … 19 counts: tap fails at n=80 only
HITMAP n=100 tileRight=105.0 btn=[100.4,125.1] stopReachableWidth=2.0 from=100.4 to=101.9

Mechanism, measured: the hit test is bounded by the Row's box, not by the tile's ClipRRect (a ClipRRect with no custom clipper does not restrict hit testing). At n=100 the Row's right edge sits at 102.2 while the tile's is at 105.0, so 2.0 px of the remnant is live and the 2.6 px beyond it is drawn-but-dead. S4's instrument tapped the centre of the visible remnant (102.7), which falls in the dead strip.

And the outcome is not "nothing happens and the tile keeps counting". The tap falls through to the tile body, whose single-tap handler pauses after the 260 ms double-tap window (lib/ui/home.dart:387-399): after400ms=paused. The timer stops counting and the tile changes to its paused rendering.

With the fonts the app ships, the failure disappears entirely (realfont_sweep.txt, 38 configurations across 1280×800 and 1024×768, n = 4…100):

RFSWEEP 1280x800 n=100 tile=94x80 stopBtn=22.1x27.5 pastTileEdge=4.3 visible=17.8
                       reachable=true result=STOPPED flutterErrors=200

reachable=true and result=STOPPED in all 38. The overflow assertion still fires at n=36, 64, 80, 100 on 1280×800 — the row does exceed its box — but it never costs a tap.


4. The BLOCKER question — answered, and the answer is no

R13 reserves BLOCKER for, among other things, an alarm that cannot be rung or silenced. Two measured facts close it (forensics_stop_button.txt):

RINGING n=30  tile=204.0x138.0 controlRowPresent=false before=ringing afterBodyTap=SILENCED
RINGING n=100 tile= 94.0x 80.0 controlRowPresent=false before=ringing afterBodyTap=SILENCED
  1. A ringing tile has no control row at all: _controls returns SizedBox.shrink() unless the status is running or paused (lib/ui/tile.dart:568-570). There is no ✕ to clip.
  2. A ringing alarm is silenced by tapping anywhere on the tile body — the whole tile, 94×80 px even at 100 timers (lib/ui/home.dart:365-373, engine.stopTimer(id)).

S4's "Why it matters" paragraph says the ✕ is "how a ringing alarm gets cleared from the board". That is false. The clipping cannot silence-block an alarm, so it is not a BLOCKER, and S4's "0 BLOCKER" count is right for a reason S4 did not state.


5. Is 100 timers realistic?

No, and the product's own data says so. First launch seeds seven timers (lib/engine/store.dart:327-342: Manouche, Mozzarella sticks, Fries, Crispy, Melt cheese, Dough, Cook chicken), and a dish can carry at most three batches (lib/engine/engine.dart:31, static const int maxBatch = 3). The shipped board is 7 tiles; the same board with every dish at full batch is 21. Reaching 100 tiles needs at least 34 distinct dish definitions, all batched out.

Worst realistic count: 30 tiles. At 30 tiles on any tablet surface, with the real fonts, there is no overflow assertion, no clipping, and the stop button measures 36.0 × 49.2 px and works.


6. Touch targets — CONFIRMED, severity corrected to MEDIUM

Re-measured with the real fonts (touch_targets_realfont.txt). The heights, which are what fail, are unchanged or slightly worse; several widths are smaller than S4 reported:

RFHEADER 1280x800 | ⚙ 51.5x40.0 | ✎ EDIT 82.5x38.0 | + NEW 83.5x38.0
RFHEADER 320x480  | ⚙ 30.0x33.0 | ✎ 28.7x31.0 | + 28.7x31.0
RFMODAL EDITOR chain=true | ✕ 29.1x36.0 | 🗑 36.9x58.0 | + Add step 508.0x43.0
RFMODAL EDITOR chain=false | ▲ 52.0x40.0 | ▼ 52.0x40.0 | Single 250.0x45.0 | Chirp 61.1x45.0 | 0:30 64.6x34.0
RFMODAL SETTINGS | English 250.0x45.0 | CLOSE 508.0x58.0 | slider 508.0x48.0
RFTOUCH 1280x800 n=30 | +10 60.8x49.2 | -10 55.5x49.2 | X 36.0x49.2 | dup 45.9x40.0

S4's count of 16 element types below 48 dp stands; the 🗑 delete is 36.9 wide, not 42.6.

I obtained the Apple artifact S4 listed as missing (wait_until="domcontentloaded" instead of networkidle, exactly the test S4 named). Capture: proof/03_market/captures/s4r_apple_hig_accessibility.txt, retrieved 2026-08-04, from https://developer.apple.com/design/human-interface-guidelines/accessibility

"Offer sufficiently sized controls. … Strive to meet the recommended minimum control size for each platform" Platform / Default control size / Minimum control size — iOS, iPadOS / 44x44 pt / 28x28 pt

That changes the grading. Android's page says "we recommend"; Apple's says "strive to meet", names 44 pt as the default and 28 pt as the minimum. Against Apple's stated minimum, every control in Cadence passes except the tile controls at n=100 (22.1 × 27.5). Neither captured page is a submission requirement, and I found nothing in either that rejects an app for control size.

Verdict: this is a real ergonomics defect for a wall tablet operated with wet or gloved hands — the editor's step-remove ✕ at 29.1 × 36.0 sitting beside a number field is the sharpest case, and the in-service ± / ✕ at 30 tiles are 49 px tall but only 36-61 px wide. It is not a store gate and it is not "wrong behaviour during service" (every measured tap landed). Per R13 that is MEDIUM, not HIGH. S4's proposed fix — one tapTarget helper that grows the invisible hit box without changing the drawn size — is the right shape and costs nothing visually; the dense-board trade-off S4 worries about does not arise because the padding is invisible.


7. Rebuild numbers — counts CONFIRMED, cost verdict corrected

The counts reproduce exactly (495 / 1475 / 221 / 2 boundaries). Measured cost of the frame those counts describe (frame_cost.txt, 60 consecutive heartbeat frames, real fonts loaded, the same setState(() {}) the ticker issues):

FRAME n=12  allRunning=true  widgetBuildsInWindow=29100  perFrameMs= 8.856 dutyCycleAt150ms= 5.90%
FRAME n=30  allRunning=true  widgetBuildsInWindow=67980  perFrameMs= 9.150 dutyCycleAt150ms= 6.10%
FRAME n=100 allRunning=true  widgetBuildsInWindow=219180 perFrameMs=28.594 dutyCycleAt150ms=19.06%
FRAME n=12  allRunning=false widgetBuildsInWindow=13260  perFrameMs= 2.787 dutyCycleAt150ms= 1.86%
FRAME n=30  allRunning=false widgetBuildsInWindow=28380  perFrameMs= 3.440 dutyCycleAt150ms= 2.29%
FRAME n=100 allRunning=false widgetBuildsInWindow=87180  perFrameMs= 9.991 dutyCycleAt150ms= 6.66%

Read carefully: this is a debug JIT build on an Apple-silicon host, with debugProfileBuildsEnabled and a per-element callback active, and it excludes GPU rasterisation. It is an upper bound on the CPU-side build+layout+paint-record work and a lower bound on total device cost.

What it settles: the heartbeat has a 150 ms budget and, at a realistic 30-tile board, spends about 9 ms of it — a 6 % duty cycle with a 16× margin. The app cannot drop a heartbeat from rebuild cost at realistic densities; there is no frame-rate defect here. Also relevant: _PiePainter.shouldRepaint is correctly implemented (lib/ui/tile.dart:785, old.p != p || old.fill != fill), so idle tiles rebuild but do not repaint — the count and the repaint are not the same thing, and S4's phrase "every tile's pie repaint" overstates what happens.

What it does not settle: power. I cannot measure battery or thermals on this hardware, and I will not assert either way. The artifact that would settle it is a dumpsys batterystats delta on a physical Android tablet over a one-hour idle-board run, HEAD versus a build with per-tile RepaintBoundary plus the idle-skip in S4's proposed fix (3). Until that exists, S4-F05's "battery and thermal budget" sentence is an unproven claim attached to a proven count. The three proposed fixes are cheap and correct regardless; the justification should be the wasted work, not an unmeasured battery figure.


8. Legibility — magnitude REFUTED, defect real, and the "two metres" premise does not survive

Re-measured with the real fonts (legibility_and_frame_cost.txt), 1280×800, effective size on glass = declared size × the FittedBox scale:

tile count "Fries" (5) "Poulet roti" (12) "Saumon grille bar" (17) 24-char maximum
1 88.76 88.76 88.76 88.76
12 32.20 32.20 28.96 21.98
20 24.22 24.22 22.10 16.78
30 19.32 19.32 17.54 13.31
42 16.10 16.10 14.23 10.80
60 14.56 13.70 8.30 6.30
100 11.20 8.24 4.99 3.79

S4's headline "24 characters render at 7.5 px on a 30-tile board" is 13.31 px with the shipped fonts — inflated 1.77×. Its 3.02 px and 2.11 px figures are for 60-character names, and the name field is capped at 24 characters (lib/ui/modals.dart:251, maxLength: 24, re-grepped in greps_recheck.txt); the 60-char maxLength fields at :336 and :480 are the voice phrase and the chain step name, neither of which flows through the FittedBox under test. The reachable worst case is the 24-character row, and it is 13.31 px at 30 tiles, not 3.0.

The two-metre claim. On the assumed target panel — a 10.1-inch 16:10 tablet at 1280×800 logical, i.e. 8.565 in wide, 149.4 px/in, so 1 logical px = 0.170 mm — the largest a dish name ever gets is 88.76 px = 15.1 mm of em box at one full-screen tile, and 32.20 px = 5.5 mm at twelve tiles. The only official distance-to-character-height rule I could capture is ADA Standards §703.5.5 (proof/03_market/captures/s4r_ada_703_visual_characters.txt, retrieved 2026-08-04, from https://www.access-board.gov/ada/#ada-703_5): for a sign 1015-1780 mm above the floor read from under 1830 mm, minimum uppercase character height is 5/8 inch (16 mm). Cap height is always less than the em box, so no tile count on this panel produces a dish name that meets it — not even a single full-screen tile.

So the honest answer to "at what tile count does a realistic dish name become unreadable at two metres" is: it never was readable at two metres by any sourced standard, at any count. The number worth giving Serge is the relative one, which is measured and stable: a name of up to 12 characters renders at 63 % of its own countdown's size at every density, and a 24-character name at 44 %. The countdown is the two-metre channel; the name is an arm's-length channel at every board size. The defect S4 found — BoxFit.scaleDown with no floor — is real and worth the fix, but it is a quality defect (MEDIUM), not the HIGH it was filed as, and the fix should be justified by the name/countdown ratio, not by a two-metre claim the board cannot meet at any density.


9. Findings contributed

S4R-F01 — The audit's UI instruments measure text 1.67× too wide, and the Phase-4 test suite inherits the error

  • Severity: MEDIUM
  • Location: proof/01_findings/S4/tests/s4_harness.dart (no font loading), consumed by s4_layout_stress_test.dart, s4_touch_targets_test.dart, s4_a11y_and_hit_test.dart; contrast with s4_screenshot_test.dart:21-43, which does load the fonts
  • What is wrong: the harness pumps HomeScreen without registering assets/fonts/*.ttf, so flutter_test's one-em-per-glyph fallback font is used for every string. Chivo Mono's advance is 0.6 em, so every control-row and name measurement is 1.67× too wide. This is the sole cause of S4-F01's 19/25 and 19/30 and of S4-F02's 7.5 px / 3.0 px. It also lands in the product: S4 proposes tile_controls_never_overflow and dish_name_never_renders_below_floor as permanent Phase-4 gates written against this harness, where they will fail on a correct app and force geometry changes to satisfy a font the app does not ship.
  • Evidence: 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
  • Why it matters for a restaurant kitchen: it does not, directly. It matters because it turned a 100-tile edge case into the audit's most consequential UI finding, and because a fix agent acting on it would re-tier the control row against a phantom.
  • Proposed fix: 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 the fix: run s4r_font_effect_test.dart; the testfont and realfont summaries must agree once the harness loads the fonts.

S4R-F02 — Where the control row overflows, part of the stop button is drawn but not tappable

  • Severity: LOW
  • Location: lib/ui/tile.dart:600 (the Row), clipped by lib/ui/tile.dart:327 (ClipRRect) (valid at 03a176e)
  • What is wrong: a ClipRRect without a custom clipper restricts painting but not hit testing, while a RenderBox refuses hits outside its own box. The two rectangles therefore differ: pixels of the ✕ that lie inside the tile but outside the Row's box are visible and dead.
  • Evidence: 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
  • Why it matters for a restaurant kitchen: at 100 tiles, 2.8 px of what looks like a button is not one. It is not reachable at any realistic count, which is why this is LOW.
  • Proposed fix: 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 the fix: stop_button_drawn_equals_tappable — for n ∈ {12, 30, 60, 100} assert tappableWidth == drawnInsideTile using the hit-path probe in s4r_missed_test.dart.

Checked and found sound (negative space)

  • The ×N batch chip is not clipped at the top. S4's clip probe only measures right, bottom and left (s4_touch_targets_test.dart:66-76), so a top overflow would have read as "inside". Measured across 28 configurations (missed_findings.txt, CHIP …): aboveTileTop is negative everywhere, minimum margin 2.1 px at 100 tiles. No defect.
  • The ±10 hit rectangles never overlap and the ✕ safety gap always holds. 21 configurations (missed_findings.txt, OVERLAP …): plusMinusOverlapPx is negative everywhere (−1.42 worst case) and minusStopGapPx is positive everywhere (+2.84 worst case). The comment at lib/ui/tile.dart:622 claiming the gap is "a safety buffer" is accurate.

10. Verdicts on all 18 S4 findings

Finding Verdict Basis
F01 stop-button overflow/clipping — HIGH REFUTED 19/25 and 19/30 are test-font artefacts (9/30 and 5/30 with the shipped fonts); release build draws the full ✕ at n=100 and a click on it stops the timer; the tap failure occurs in 2 of 38 test-font configurations and pauses rather than doing nothing; ringing tiles have no ✕ at all. Residual real defect: overflow at n ≥ 36 and 4.3 px of clipping at n=100. Correct severity MEDIUM.
F02 name shrinks with no floor — HIGH REFUTED in magnitude 7.54 px is 13.31 px with the shipped fonts; 3.02/2.11 px are 60-character names the name field cannot produce (maxLength: 24). The missing floor is real. Correct severity MEDIUM.
F03 16 element types under 48 dp — HIGH CONFIRMED, severity → MEDIUM Re-measured with real fonts; heights unchanged, 🗑 narrower than reported. Apple's captured table gives 44 pt default / 28 pt minimum; Android's is "we recommend". Every measured tap landed, so it is not wrong behaviour during service.
F04 heartbeat gated behind three un-timed awaits — HIGH CONFIRMED BOOT[hang] heartbeatRebuildsOver20Ticks=0 bannerVisible=false reproduced; grep -rn "\.timeout(" lib/ still returns only voice.dart:168 (greps_recheck.txt).
F05 495/1475/221 rebuilds, 2 boundaries — MEDIUM CONFIRMED, cost claim corrected Counts reproduce; measured 8.9 ms/frame at 30 tiles (6 % of the 150 ms budget) in a debug build. No frame-rate defect. Power unmeasured — see §7 for the artifact that would settle it.
F06 urgency ramp isoluminant/non-monotonic — MEDIUM CONFIRMED RAMP mint-vs-amber contrast=1.098:1, luminance 0.4546 → 0.5040 → 0.2918 reproduced exactly.
F07 five WCAG failures — MEDIUM CONFIRMED All five reproduce verbatim (repro_contrast_boot_edge.txt), plus nine more non-text pairs in the same output.
F08 no semantics — MEDIUM CONFIRMED SEM nodes=17 isButtonFlag=0 withLabel=13 withTapAction=9; grep for Semantics|semanticLabel|tooltip over lib/ returns nothing.
F09 blanket withNoTextScaling — MEDIUM CONFIRMED SCALE=2.0 wrapper=false … overflowErrors=24 vs wrapper=true … 12 reproduced.
F10 failed save never raises the banner — MEDIUM CONFIRMED lib/ui/home.dart:446 Diag.fail('save-def' with no isCritical; no clearCritical anywhere in home.dart.
F11 every icon is a glyph no bundled font contains — MEDIUM CONFIRMED, visually S4's own board_1280x800_n12_running.png draws the ✕ as an empty tofu box, and my CanvasKit release run logged "Could not find a set of Noto fonts to display all missing characters".
F12 banner 13.5 px, never clears — MEDIUM CONFIRMED Banner rendered in my release web capture as a single thin strip above the grid; audio-init has no clear site in lib/.
F13 1,541 lines at 0.00 % coverage — MEDIUM CONFIRMED Baseline fact; unchanged.
F14 duplicated grid hit-test, unused tickMs — MEDIUM CONFIRMED grep -rn tickMs lib/ test/ returns the declaration only.
F15 ~120 inline geometry literals — MEDIUM CONFIRMED Spot-checked tickMs, the ramp anchors and the hit paddings.
F16 dead symbols — LOW CONFIRMED grep -rn "C.mint\|C.logoInk" lib/ returns nothing outside theme.dart.
F17 +2880:00 after 48 h — LOW CONFIRMED EDGE ringing 48h -> [+8888:88, +2880:00] reproduced.
F18 what was checked and found sound — LOW CONFIRMED Grid solver clean in every configuration I ran; degenerate data renders; idle boards never overflow.

11. Coverage manifest — every file in S4's scope

File Lines What I checked in it, independently
lib/ui/tile.dart 819 Control-row tiering re-measured in 30 configurations twice (test font vs bundled fonts) and in 38 more in the real-font sweep; stop-button hit path probed point-by-point at 0.25 px resolution; tap outcome read from TileView.r.status at n = 2/12/30/60/100; ringing-tile control row confirmed absent (:569-570); ClipRRect at :327 confirmed paint-only for hit testing; name FittedBox scale re-measured with real fonts for 4 name classes × 12 counts × 2 surfaces; ×N chip geometry vs the tile's top edge in 28 configurations; ±10/✕ hit-rect separation in 21; _PiePainter.shouldRepaint read at :785; ✕/✎ glyph fallback confirmed visually in two rendered boards
lib/ui/home.dart 722 Boot-hang reproduced (BOOT[hang] … =0); heartbeat frame cost measured over 60 frames at 3 densities × running/idle; rebuild counts and repaint-boundary audit reproduced; tap disambiguation read at :348-401 and exercised (single tap → pause after 260 ms; ringing → stopTimer); Diag.fail/clearCritical call sites re-grepped; grid placement observed at 1280×800 and 1024×768 for 19 counts each; critical banner observed rendered in a real release build
lib/ui/modals.dart 746 Every dialog control re-measured with the real fonts in both single and chain mode at two surfaces; maxLength fields re-grepped and attributed (:251 name = 24, :336 phrase = 60, :480 step name = 60, :533 = 3), which is what bounds the reachable name length in F02; chip and pill heights re-derived (42 → 45 with real font metrics)
lib/ui/header.dart 215 All three button hit rectangles re-measured at 7 widths with the real fonts; degradation ladder re-observed (word labels drop below 800 px, clock below 560 px); header rendered and read in the release web capture
lib/ui/grid_layout.dart 109 Solver exercised through the real board at 5 surfaces × 6 counts twice, plus 19 counts × 2 surfaces in the sweep, plus 12 counts × 2 surfaces in the legibility sweep — no negative geometry, no frame overflow, cell count always ≥ n in every run. No defect found
lib/ui/theme.dart 82 All 47 contrast pairs and the full urgency ramp recomputed from the live palette; C.mint/C.logoInk consumer search repeated; fmtTime/fmtUp re-exercised at 1 h / 12 h / 48 h
lib/ui/logo.dart 18 Read in full; single Image.asset with no state and no error path; rendered in the release capture at the header's left edge. No defect
lib/main.dart (cited, owned by S14) 59 MediaQuery.withNoTextScaling at :48 exercised with and without the wrapper at three scale factors. My experiment harness patched this file in the copy only to seed a board for the release build; the copy's patched file is stored at proof/01_findings/S4_refute/tests/main_dart_with_seed_harness.dart.txt. Nothing under lib/ui/ was modified in any run

12. What would overturn my own verdicts

  • §2 (release behaviour): my release evidence is a Flutter web AOT build rendered through CanvasKit at 1280×800, dpr 1. Layout is the same Dart code in every target, and the release APK builds (baseline), but the artifact that would close the last gap is the same 100-timer board screenshotted from app-release.apk on a tablet-configured Android device, plus one adb input tap on the ✕. adb is outside the paths this workspace's guard hook permits, so I did not run it.
  • §7 (power): named in that section — a dumpsys batterystats delta over an hour, HEAD versus the boundary+idle-skip build.
  • §6 (store gating): I claim only what the two captured guidance pages say. A claim that a store rejects for control size would need a capture of the App Review Guidelines and the Play Developer Policy Center; I make no such claim.

Stream S5: finding and refutation

S5 — Error-handling discipline (repo-wide)findings/S5_error_handling.md · raw .md

S5 — Error-handling discipline (repo-wide)

Subject: the app repository at pinned commit 03a176e72ef0075eec86b8915cbe6e93042a3b9d, version 0.4.12+18. Scope: all 18 files in lib/ (4,853 lines) plus the error-handling posture of android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt and ios/Runner/AppDelegate.swift. Work performed on a read-only copy at a scratch working copy; the subject repo was never modified (R10). Every command recorded under proof/01_findings/S5/.


0. Verdict first

No. This codebase does not handle errors correctly.

It handles anticipated errors unusually well — 30 of the 38 Dart catch sites route into a single choke point (Diag.fail) that logs, persists to the flight recorder, and raises an operator banner. That part is better than most shipping Flutter apps.

It handles unanticipated errors not at all. There is no FlutterError.onError, no PlatformDispatcher.instance.onError, no runZonedGuarded, and no ErrorWidget.builder anywhere in the project, and no crash reporting or telemetry of any kind. The consequence is exact and provable: any error the authors did not predict produces a grey rectangle on a kitchen tablet, writes nothing to the journal the whole pilot programme is built on, and is never transmitted anywhere. The app's own flight recorder cannot see the class of failure it was built to catch.

Three changes, in order of value:

  1. Install the three global handlers in main() and route all three into Diag.fail — so that an unpredicted error becomes a journal line and a banner instead of a grey box (S5-F2).
  2. Make Engine.tick's catch (_) report before it deletes (S5-F1) and make the catch (_) in Journal._rotate stop erasing the log (S5-F3). These are the two handlers whose response to an error is worse than the error.
  3. Stop discarding the booleans the native side returns for speak and setVoice, and give the Kotlin channel an error path (S5-F7). Today a failed announcement and a failed alarm-volume write are indistinguishable from success, on both sides of the boundary.

1. Findings

S5-F1 — Engine.tick's catch deletes a live timer and tells nobody

  • Severity: HIGH
  • Location: lib/engine/engine.dart:338 (try at :305), valid at 03a176e
  • What is wrong: The try block at engine.dart:305-337 does not only wrap the engine's own state arithmetic — it wraps the entire alarm side-effect chain, because _fireAlarm (:325, :331), _alarmRepeat (:336) and host.onStepAdvance (:328) are all called synchronously from inside it. Everything _HomeScreenState does on an alarm — Journal.log, engine.labelFor, i18n.call, sounds.ringtone, sounds.hapticFire, backstop.showNow — therefore executes inside this try. The handler catches Object (both Exception and Error, with no type filter and no bound variable), deletes the run entry of the timer that was ringing, and reports nothing: no Diag.fail, no Journal.log, no banner, no state the UI can distinguish from "the cook stopped it". The dish's tile silently returns to idle and will never ring again.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: a pan of fries has a timer on the board and the timer vanishes mid-service. The tile reads idle, which is exactly what it reads for a dish nobody started, so the cook has no way to tell the difference. Nothing rings, nothing is announced, and the journal — the artefact the pilot exists to produce — contains no trace of it. The failure is invisible in the kitchen and invisible afterwards.
  • Proposed fix: 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 the fix: 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.
  • Why not BLOCKER: I could not construct a production sequence in which the host callbacks throw — every force-unwrap reachable from onAlarmFire/onStepAdvance is protected by the reconcile invariants at engine.dart:81-91, which I verified by test. The exact artefact that would flip this to BLOCKER: any demonstrated synchronous throw from _HomeScreenState.onAlarmFire, onAlarmRepeat or onStepAdvance on a real device — the most likely candidates being Vibration.vibrate (audio.dart:99) or HapticFeedback.lightImpact (audio.dart:114) throwing synchronously on a device without the plugin, which requires a physical-device run to settle.

S5-F2 — No global error handler and no crash reporting: an unpredicted error is invisible twice

  • Severity: HIGH
  • Location: lib/main.dart:22-35 (the only place these can be installed), valid at 03a176e
  • What is wrong: main() initialises the binding, opens the store, boots the journal, enables the wakelock and calls runApp. It installs no FlutterError.onError, no PlatformDispatcher.instance.onError, no runZonedGuarded, and no custom ErrorWidget.builder. No other file installs them either. Separately, the project depends on no crash-reporting or telemetry package. The result is two independent blind spots that compound: an unpredicted error neither reaches the on-device journal (so the pilot's exported log cannot show it) nor any backend (so the developer never learns it happened).
  • Evidence: proof/01_findings/S5/02_no_global_handlers.txtgrep -rn "FlutterError.onError\|PlatformDispatcher.instance.onError\|runZonedGuarded\|ErrorWidget.builder\|addErrorListener" lib/ android/ ios/ → no output, EXIT_CODE=1. proof/01_findings/S5/03_no_crash_reporting.txtgrep -rniE "crashlytics|sentry|firebase|bugsnag|appcenter|datadog|rollbar|analytics|telemetry" lib/ test/ pubspec.yaml pubspec.lock android/ ios/ → no output, EXIT_CODE=1. pubspec.yaml:11-22 lists 10 direct dependencies; none is a reporter. What happens instead, from the pinned Flutter 3.44.8 SDK itself (proof/01_findings/S5/10_flutter_release_errorwidget.txt, packages/flutter/lib/src/rendering/error.dart:109-121): ```dart /// The color to use when painting the background of [RenderErrorBox] objects. /// /// Defaults to red in debug mode, a light gray otherwise. static Color backgroundColor = _initBackgroundColor();

static Color _initBackgroundColor() { var result = const Color(0xF0C0C0C0); assert(() { result = const Color(0xF0900000); return true; }()); return result; } and `packages/flutter/lib/src/widgets/framework.dart:5654-5655`: *"The default behavior is to show the exception's message in debug mode, and to show nothing but a gray background in release builds."* The default `FlutterError.onError` is `presentError` (`foundation/assertions.dart:941`, `proof/01_findings/S5/11_flutter_error_ondefault.txt`), which dumps to the console — a destination that does not exist on a tablet in a kitchen. Proven for the async half by test `S5-F3 … an unawaited future that throws reaches neither Diag nor Journal` (`proof/01_findings/S5/01_s5_tests_baseline.txt`): the error is observed by a `runZonedGuarded` installed *by the test*, while `Diag.log` and `Diag.critical` stay empty. In the app there is no such zone, so there is no observer at all. - **Why it matters for a restaurant kitchen:** the release answer to an unpredicted error is a light grey rectangle, `Color(0xF0C0C0C0)`, with no text. A cook mid-service sees a grey box where the countdown was — no message, no error, nothing to report to anyone, and no way to distinguish it from a rendering glitch. Afterwards, the exported journal — the single artefact the pilot produces — contains no line about it, and because nothing is transmitted, the developer never learns any of the 38 handled paths or any unhandled one ever fired on any device. For a product intended to be sold to restaurants, that means field defects are discoverable only by a cook phoning to complain. - **Proposed fix:** in `main()`, before `runApp`:dart FlutterError.onError = (d) { FlutterError.presentError(d); Diag.fail('flutter-${d.library ?? 'framework'}', d.exception, isCritical: true); }; PlatformDispatcher.instance.onError = (e, st) { Diag.fail('async-uncaught', e, isCritical: true); return true; }; `` and add a'flutter'/'async'prefix branch to the banner map athome.dart:675-687. Both are compliance plumbing, not a feature (R6). Crash reporting is a dependency decision for Serge and the project owner, not for this audit — but note the two handlers above are what makes the *existing* journal cover the gap without adding a vendor. - **How to prove the fix:** a test that throws from a widgetbuildinsidetester.pumpWidgetand assertsDiag.loggains aflutter-widgets libraryentry; and a test that creates an unawaited throwingFutureand assertsDiag.loggains anasync-uncaught` entry. Both are red today (the second is already written and passing as a proof of absence).


S5-F3 — Journal._rotate erases the entire flight recorder when it cannot read it

  • Severity: HIGH
  • Location: lib/journal.dart:200-202 (try at :193), valid at 03a176e
  • What is wrong: _rotate runs at boot whenever the journal exceeds 3 MB (journal.dart:72). It reads the whole file, keeps the last 1 MB, and rewrites it. Its error handler responds to any read or write failure by writing an empty string over the file — that is, by deleting every line of history. The catch is untyped and unbound (catch (_)), so it treats a transient I/O error, an encoding error, and a programming bug identically, and its recovery is the most destructive action available.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the journal exists precisely because the failures that matter (screen off, app swiped away, battery saver, an OS kill) are the ones the app cannot observe from the inside — its own header comment says so at journal.dart:1-14. Rotation fires on the largest journals, which are the ones covering the longest services and therefore holding the most evidence. A truncated tail is not exotic: a force-stop during writeAsString is the exact scenario the file was built to record, and it is the scenario that makes the file unreadable. The failure mode is therefore self-destructive: the kill the journal was meant to prove destroys the proof at the next boot.
  • Proposed fix: 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 the fix: 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.

S5-F4 — Journal write failures are reported to nobody, and Journal.ready keeps saying yes

  • Severity: MEDIUM
  • Location: lib/journal.dart:175-177, and lib/journal.dart:108-111, valid at 03a176e
  • What is wrong: when the append at journal.dart:170 fails, the handler calls debugPrint and nothing else. There is no Diag.fail (which would be circular — Diag.fail writes to the journal — so the author's choice is understandable), no flag, and no change of state: Journal.ready still returns true (journal.dart:53, _file != null), so the Settings screen still shows the "send the journal" block (modals.dart:673) and the app still believes it has a flight recorder. Every subsequent Journal.log call appears to succeed. journal.dart:108 has the mirror problem at init time: it sets _file = null and debugPrints, which hides the Settings block but produces no error indication — a missing button is not a message.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the operator taps "send the journal", the share sheet opens, and what is sent is a file frozen at the moment writing started failing — with no marker saying so. The investigation that follows reads a truncated log as a complete one, which is worse than reading no log: it makes the missing events look like events that did not happen.
  • Proposed fix: 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 the fix: 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.

S5-F5 — A failure raised isCritical: true that cannot reach the operator banner

  • Severity: MEDIUM
  • Location: lib/engine/store.dart:270 raising it; lib/ui/home.dart:675-689 dropping it, valid at 03a176e
  • What is wrong: Diag.fail(..., isCritical: true) adds a scope string to Diag.critical (diagnostics.dart:32-34). _criticalBanner translates scopes to messages with a fixed chain of six startsWith tests — voice, audio, save, load, wakelock, backstop — and if none matches, msgs is empty and the builder returns SizedBox.shrink() (home.dart:689). Of the 12 distinct critical scopes emitted across lib/, exactly one — 'migrate-zone-sound' (store.dart:270) — matches no prefix. It is therefore recorded as a critical failure, written to the journal, and shown to the operator as nothing at all.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: migrateZoneSounds is the v0.4.11 one-shot that hands each timer the ringtone its deleted zone used to carry. When it fails, it returns 0 and — by the deliberate design at store.dart:260-261 — leaves the legacy key in place to retry next boot. Its own comment says the alternative is "silently hand the whole kitchen a default tone". The failure is real and the code treats it as critical; the operator simply is not told, so a kitchen whose ringtones did not migrate has no signal that anything is pending.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/ui/modals.dart:698-717, with lib/journal.dart:233-236, valid at 03a176e
  • What is wrong: _sendJournal sets _sending = true, awaits Journal.exportCopy(), and returns early if the result is null. That early return at modals.dart:702 happens inside the try, so the catch at :712 — the only thing that would call Diag.fail — never runs, while the finally at :714 faithfully resets the button. exportCopy returns null on any failure after only a debugPrint (journal.dart:234). Net effect: the operator taps "send the journal", the label flickers from "sending…" back to "send", and nothing happens, forever, with no message anywhere on the device and no entry in the diagnostics log.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: this button is the entire mechanism by which the pilot kitchen returns diagnostic data. If it fails on a particular tablet — a full /data/local/tmp, a vendor with no external storage — the operator has no way to know the export failed rather than succeeded quietly, and no data ever comes back from that device. The pilot silently loses a participant.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:57-59, :79, :87, :91-92, :96-97, :105, :150-151, :172; consumed at lib/audio/voice.dart:134, :166-168 and lib/ui/home.dart:211-213, valid at 03a176e
  • What is wrong: this is the pattern, not the per-site depth (S3 owns that). Two facts combine. First, MainActivity.kt contains zero result.error(...) calls — every one of its eight catch (_: Exception) sites swallows and then replies result.success(...). There is therefore no code path by which a native failure can arrive on the Dart side as an error, which makes every Dart-side .catchError on cadence/volume and cadence/tts unreachable for native faults. Second, where the native side does signal failure by returning false, Dart throws that value away: voice.dart:134 awaits setVoice and ignores the result, then logs voix choisie: $best at :135 regardless; voice.dart:166-168 awaits speak and ignores the result. The concrete consequence for the volume path: MainActivity.kt:57-59 swallows a setStreamVolume failure and replies success(null), so home.dart:211's .catchError never fires, _systemVolumeOk stays true, and AlarmVolume's entire stated guarantee — "a stray-low hardware rocker can never make the next alarm inaudible" (alarm_volume.dart:14-16) — is defeated with no indication anywhere.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the journal line parole "The fries are ready" is written at voice.dart:165 before the call, and no line is ever written to contradict it. So a tablet whose TTS engine refuses every utterance produces a journal that reads exactly like a tablet that announced perfectly. The one feature described in the source as "the product" (voice.dart:98-103) can fail completely while the flight recorder certifies it worked. Same for the alarm volume: the app can be certain it imposed 100% on the alarm stream while the stream sits wherever a hardware rocker left it.
  • Proposed fix: (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 the fix: 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

  • Severity: MEDIUM
  • Location: 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, valid at 03a176e
  • What is wrong: lib/main.dart:28-32 shows the codebase's own correct pattern for a fire-and-forget future — .then(...).catchError(...), with the handler routing into Diag.fail('wakelock', e, isCritical: true). That pattern is applied at exactly four sites in lib/ (main.dart:30, store.dart:141, store.dart:177, home.dart:211, voice.dart:196, alarm_backstop.dart:230 — six, counting the two in store). It is not applied at eleven other sites that create a Future and discard it. Because there is no PlatformDispatcher.instance.onError (S5-F2), a rejection at any of them is an unhandled async error with no destination whatsoever. The most exposed are the three that run on every user interaction or every minute: | Site | Call | Fires | |---|---|---| | audio.dart:114 | HapticFeedback.lightImpact(); | every button press, via onClick | | audio.dart:99, :107 | Vibration.vibrate(...) | every alarm, repeat, and step change | | journal.dart:152 | _prefs?.setInt(_kLastBeat, now); | every 60 s heartbeat, forever | | main.dart:33 | SystemChrome.setEnabledSystemUIMode(...) | once at boot | | home.dart:141-144 | voice.init(...).then((_) {...}).then with no .catchError | once at boot | | home.dart:202 | Journal.markCleanExit(); — its _prefs?.setBool at journal.dart:189 is unguarded | on every detach | | voice.dart:63, :146, :177, :201 | _drain() — its tail at :174-177 sits outside the inner try | on every announcement |
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: these are the low-consequence sites individually — a missed haptic tick is nothing. Their significance is cumulative and diagnostic: they are the most likely producers of unhandled async errors on an unusual device, and today every one of them is routed to a console nobody reads. That makes the first field report from a problem tablet unactionable. The two with real consequence are voice.init (a .then with no .catchError, on the boot path of the app's key feature) and journal.dart:152 (a rejection here means the kill-detection timestamp stops advancing, so the next session's "session precedente tuee" line reports the wrong time, and nothing says so).
  • Proposed fix: 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 the fix: 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.

S5-F9 — lib/ui/tile.dart: 819 lines, no error handling, no coverage, all countdown rendering

  • Severity: MEDIUM
  • Location: lib/ui/tile.dart (whole file), specifically :186, :187, :193, :200, :364, :498, :500, :501, :731, valid at 03a176e
  • What is wrong: tile.dart is the largest file in lib/ and the only place a countdown is ever drawn. It contains no try/catch, and nine force-unwrap sites, every one of which executes inside build(). Combined with S5-F2 (no ErrorWidget.builder), a throw from any of them replaces the tile with Color(0xF0C0C0C0) and writes nothing anywhere. Its baseline coverage is 0.00% and no test imports it, so none of these paths has ever been executed by the suite. I verified that the invariants currently hold: every one of these unwraps depends on the relationship between a RunEntry and its TimerDef, and Engine.reconcile (engine.dart:81-91) drops any run entry that violates it — running-without-deadline, paused-without-remaining, chained run on a non-chained def, and out-of-range stepIndex. reconcile is called from Store.load (store.dart:86), which runs in initState before the first frame, and saveDef clears the run entry before reshaping a definition (engine.dart:378). So this is a gap in defence, not a live defect.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: the invariant is currently maintained by one function in a different layer, with no test asserting that tile.dart survives its violation and no errorBuilder if it ever does. The day someone adds a run-entry field, or a migration writes a shape reconcile does not check, the visible result in a kitchen is a grey rectangle where the countdown was, and the journal records nothing.
  • Proposed fix: 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.
  • How to prove the fix: 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.

S5-F10 — Two empty catch blocks

  • Severity: LOW
  • Location: lib/journal.dart:128, lib/journal.dart:231, valid at 03a176e
  • What is wrong: two } catch (_) {} with no body at all. They are the only two in lib/. :231 is correct and documented — the external-storage copy is explicitly best-effort and the primary copy has already succeeded (journal.dart:227), so there is nothing to report. :128 is not: a failure to describe the device silently degrades Journal.device from "Samsung SM-T500 (samsung) · Android 13 · SDK 33" to "android", and that string is stamped on every session header (journal.dart:84), shown in Settings (modals.dart:683), used to name the exported file (journal.dart:215-220) and put in the share-sheet subject (modals.dart:707).
  • Evidence: verbatim, lib/journal.dart:114-130: dart static Future<String> _describeDevice() async { try { final info = DeviceInfoPlugin(); ... } catch (_) {} return Platform.operatingSystem; }
  • Why it matters for a restaurant kitchen: with several pilot tablets, the exported logs from a device whose probe failed are all named cadence-log-android-… and all say APPAREIL android, so two kitchens' logs become indistinguishable. It is a diagnostics-quality defect, not a service defect.
  • Proposed fix: } 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 the fix: 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.

2. Full classification table — every catch site in lib/ (38) plus the platform layer

Classification: CORRECT = handles and reports through Diag.fail/Journal, or is a documented no-consequence best-effort. SWALLOWED = nobody learns (no Diag, no Journal, no operator- visible state change). OVER-BROAD = hides programming errors alongside the expected runtime failure. WRONG = leaves the program or the data in an inconsistent or degraded state. Where more than one applies, the worst is shown.

# file:line Catch, verbatim Class What the kitchen experiences
1 lib/main.dart:30 }).catchError((e) {Diag.fail('wakelock', e, isCritical: true); CORRECT Banner "screen may sleep". The reference pattern for the whole codebase.
2 lib/engine/engine.dart:338 } catch (_) {run.remove(t.id); host.persistRun(); WRONG The ringing timer disappears; the tile reads idle; nothing rings again; no message, no journal line. S5-F1
3 lib/engine/store.dart:37 } catch (e) {Diag.fail('load-$key', 'stored value has wrong type: $e'); return null; CORRECT Banner "load failed"; the setting falls back.
4 lib/engine/store.dart:46 idem (_readBool) CORRECT idem
5 lib/engine/store.dart:55 idem (_readDouble) CORRECT idem
6 lib/engine/store.dart:74 } catch (_) { dropped++; } CORRECT Per-entry salvage; the aggregate is reported at :79 via _preserveCorrupt, which is critical.
7 lib/engine/store.dart:82 } catch (err) { _preserveCorrupt(_kRun, rawRun, err); } CORRECT Raw value copied to .corrupt; banner "load failed". Best handler in the codebase.
8 lib/engine/store.dart:100 } catch (_) { dropped++; } CORRECT idem #6, for the defs/clones lists.
9 lib/engine/store.dart:109 } catch (err) { _preserveCorrupt(key, raw, err); return []; } CORRECT idem #7.
10 lib/engine/store.dart:125 } catch (e) { Diag.fail('load-$key-preserve', e); } CORRECT Reported; non-critical, correctly (the preservation is itself best-effort).
11 lib/engine/store.dart:141 }).catchError((e) { Diag.fail('save-$key', e, isCritical: true); }); CORRECT Banner "save failed" — the operator learns the board is not persisting.
12 lib/engine/store.dart:144 } catch (e) { Diag.fail('save-$key', e, isCritical: true); } CORRECT Synchronous twin of #11.
13 lib/engine/store.dart:177 }).catchError((e) { Diag.fail('save-set-$key', e); }); CORRECT Settings write; non-critical by design (comment at :173).
14 lib/engine/store.dart:269 } catch (err) { Diag.fail('migrate-zone-sound', err, isCritical: true); return 0; } SWALLOWED at the UI Raised critical, but no banner prefix matches → the operator sees nothing. S5-F5
15 lib/journal.dart:108 } catch (e) { _file = null; debugPrint(...); } SWALLOWED No flight recorder for the whole session; the Settings journal block just isn't there. S5-F4
16 lib/journal.dart:128 } catch (_) {} SWALLOWED / OVER-BROAD Every log from this tablet is named android, indistinguishable from any other. S5-F10
17 lib/journal.dart:175 } catch (e) { debugPrint('[cadence] journal write failed: $e'); } SWALLOWED The journal silently stops recording; Journal.ready still says yes. S5-F4
18 lib/journal.dart:200 } catch (_) { await f.writeAsString(''); } WRONG 3 MB of pilot evidence replaced with an empty file, silently. S5-F3
19 lib/journal.dart:231 } catch (_) {} CORRECT Documented best-effort USB copy; the primary copy already succeeded. No consequence.
20 lib/journal.dart:233 } catch (e) { debugPrint(...); return null; } SWALLOWED The "send journal" button does nothing, forever, with no message. S5-F6
21 lib/alarm_backstop.dart:92 } catch (e) { _ready = false; Diag.fail('backstop-init', e, isCritical: true); } CORRECT Banner "backstop down"; the in-app engine still covers the foreground.
22 lib/alarm_backstop.dart:204 } on ArgumentError catch (e) { … Diag.fail('backstop-past', e); } CORRECT The only typed catch in lib/. Correct: this is the specific, expected past-date rejection.
23 lib/alarm_backstop.dart:208 } catch (e) { with e is PlatformException && e.code == 'exact_alarms_not_permitted' CORRECT Discriminates properly; degrades to inexact and re-raises; banner.
24 lib/alarm_backstop.dart:230 .catchError((e) { Diag.fail('backstop-cancel', e); }); CORRECT Reported; a failed cancel risks a phantom notification, correctly non-critical.
25 lib/alarm_backstop.dart:261 } catch (e) { Diag.fail('backstop-show', e); } CORRECT Reported. Arguably should be critical (a background alarm did not show), but it is not silent.
26 lib/alarm_backstop.dart:275 } catch (e) { Diag.fail('backstop-clear', e); } CORRECT Reported.
27 lib/audio/audio.dart:51 } catch (e) { Diag.fail('haptics-detect', e); } CORRECT _canVibrate stays false; no haptics, correctly non-critical.
28 lib/audio/audio.dart:75 } catch (e) { Diag.fail('audio-play', e, isCritical: critical); } CORRECT Banner "audio down" when the failed sound was an alarm. Exactly right.
29 lib/audio/voice.dart:64 } catch (e) { _ready = false; Diag.fail('voice-init', e, isCritical: true); _discardQueue(); } CORRECT Banner "voice down"; the queue is emptied with a journal line at :75.
30 lib/audio/voice.dart:86 } catch (e) { Diag.fail('voice-locale', e); } CORRECT Reported; the engine keeps the previous language.
31 lib/audio/voice.dart:139 } catch (e) { Diag.fail('voice-pick', e); } CORRECT Reported; the engine's default voice is used.
32 lib/audio/voice.dart:169 } on TimeoutException { Diag.fail('voice-speak', 'native speak timed out (engine wedged?)'); } CORRECT Typed, reported, and the queue unjams.
33 lib/audio/voice.dart:171 } catch (e) { Diag.fail('voice-speak', e); } CORRECT Reported. But see S5-F7: a native false never reaches here.
34 lib/audio/voice.dart:196 .catchError((e) { Diag.fail('voice-stop', e); }); CORRECT Reported; comment at :195 correctly explains why a sync try would not see it.
35 lib/ui/home.dart:138 } catch (e) { Diag.fail('audio-init', e, isCritical: true); } CORRECT Banner "audio down" before the first tick.
36 lib/ui/home.dart:211 .catchError((e) { Diag.fail('volume-set', e); }); CORRECT in Dart Unreachable for native faults — MainActivity.kt:58 swallows and returns success. S5-F7
37 lib/ui/home.dart:227 } catch (e) { Diag.fail('volume-channel', e); } CORRECT _systemVolumeOk stays false → app-level gain fallback, as designed.
38 lib/ui/modals.dart:712 } catch (e) { Diag.fail('journal-export', e); } CORRECT for what it catches But the early return at :702 bypasses it entirely. S5-F6

Platform layer — MainActivity.kt (8 sites) and AppDelegate.swift

At the pattern level only; S3 owns the per-site depth.

# file:line Catch, verbatim Class Consequence
K1 MainActivity.kt:58 catch (_: Exception) {} then result.success(null) WRONG The alarm-stream write silently fails; Dart believes the level was applied. S5-F7
K2 MainActivity.kt:79 catch (_: Exception) { emptyList<Map<String, Any>>() } SWALLOWED Empty voice list → voice.dart:107 raw is! List is false, loop finds nothing, voix: aucune voix adaptee. Degrades quietly but plausibly.
K3 MainActivity.kt:87 catch (_: Exception) { false } SWALLOWED setVoice fails; Dart discards the false and logs voix choisie anyway. S5-F7
K4 MainActivity.kt:92 catch (_: Exception) {} then result.success(null) SWALLOWED Language never set; announcements come out in the wrong language with no record.
K5 MainActivity.kt:97 catch (_: Exception) {} then result.success(null) SWALLOWED Speech rate never set. Cosmetic.
K6 MainActivity.kt:105 catch (_: Exception) {} SWALLOWED tts.stop() fails; completeAllSpeaks() still runs so the Dart queue does not hang. Low consequence.
K7 MainActivity.kt:151 catch (_: Exception) { TextToSpeech.ERROR }result.success(false) SWALLOWED at the Dart end The announcement never happens; voice.dart:166 discards the false; the journal at :165 already claims it was spoken. S5-F7
K8 MainActivity.kt:172 catch (_: Exception) {} in onDestroy CORRECT Teardown-only, no consumer.
S1 AppDelegate.swift:149 } catch { ttsReady = false; result(false) } CORRECT The only real error path across both platform files, and Dart does check it (voice.dart:48) → banner "voice down".
S2 AppDelegate.swift:54-59 result(nil) for both volume methods, no catch CORRECT Documented and deliberate: iOS has no alarm stream, so nil is the capability answer, not an error (home.dart:222-224 treats it that way).

Counts. lib/ (38 sites): CORRECT 31, SWALLOWED 5, WRONG 2, OVER-BROAD 0 as a primary classification — 1 site (journal.dart:128) is over-broad as a secondary trait, and two more (engine.dart:338, journal.dart:200) are over-broad and wrong, so they are counted as WRONG, the worse label. Platform layer (10 sites): CORRECT 3, SWALLOWED 6, WRONG 1. Combined (48 sites): CORRECT 34, SWALLOWED 11, WRONG 3, OVER-BROAD 0 primary / 3 secondary.

Typed catches across all 48 sites: threeon ArgumentError (alarm_backstop.dart:204), on TimeoutException (voice.dart:169), and the discriminating e is PlatformException && e.code at alarm_backstop.dart:210. Every other Dart site catches Object; every Kotlin site catches Exception. That is defensible for a store/platform boundary, where a wrong-typed SharedPreferences value arrives as a TypeError (an Error, not an Exception) — a narrower catch would let it through — but it means no site in lib/ distinguishes a programming bug from an expected runtime failure. The one place that matters is engine.dart:338 (S5-F1).


3. The nine files with zero error handling — correct, or a gap?

This is the distinction the stream exists to draw. Six are correct, one is correct by an explicit design contract, one is a gap, and one is a LOW note.

File Lines Can anything in it throw? Verdict
lib/diagnostics.dart 54 No. log.add/removeAt/_warned.add/ValueNotifier assignment cannot throw; Journal.log (called at :37) returns immediately when _file == null (journal.dart:134) and its only other work is a list append and an unawaited _flush(). Correct. And load-bearing: Diag.fail runs inside 30 catch blocks, so if it could throw it would convert every handled error into an unhandled one. Verified it cannot.
lib/i18n.dart 167 No. The two ! sites (:143, :145) apply to ?? _strings['en'], a compile-time const map that is never null; a missing key returns the key itself rather than throwing. Correct, and deliberately so — the comment at :141-142 says exactly this.
lib/engine/models.dart 160 Yes, by design. j['id'] as String (:66), (j['sec'] ?? 5) as int (:22), j['parentId'] as String (:157) and five siblings throw TypeError on wrong-typed stored JSON. Correct by contract. Every fromJson call is wrapped in per-entry salvage one layer up (store.dart:98-102 and :72-76), which drops the bad entry, counts it, preserves the raw value to <key>.corrupt and raises a critical Diag. Throwing loudly and recovering per-entry is the right split; test/robustness_test.dart:41 already covers it.
lib/audio/alarm_volume.dart 68 No. sane() handles NaN/Infinity explicitly (:44-45); the only outward call is the injected apply, whose implementation (home.dart:209-218) has its own .catchError. Correct.
lib/ui/theme.dart 82 fmtTime(double s) calls s.round() (:78), which throws UnsupportedError on NaN/Infinity. Its two call sites (tile.dart:189, :196, :200) feed it a value derived from integer millisecond subtraction, always finite. fillFor compares against NaN safely (all comparisons false → returns the red constant). Correct today, LOW note: fmtTime has no guard and the file has 0.00% coverage, so nothing asserts the finiteness contract. One line — if (!s.isFinite) return '0:00'; — would close it.
lib/ui/grid_layout.dart 109 No. Negative geometry is clamped by math.max(0.0, …) at :91-94; zero timers is an explicit case (grid_layout_test.dart:145). Correct. 100.00% coverage — the only file in lib/ that is both fully covered and provably total.
lib/ui/header.dart 215 No. Stateless, no force-unwrap, no async, no platform call. Correct (0.00% coverage, but nothing to guard).
lib/ui/logo.dart 18 Image.asset('assets/logo/mark_white.png') with no errorBuilder. A decode failure is reported through FlutterError.onError — which is the default (S5-F2), i.e. the console — and paints nothing. Correct today, LOW note: the asset is present on disk and bundled (pubspec.yaml:44-46 declares assets/logo/), so this cannot fire; an errorBuilder is one line of insurance for a header mark that would otherwise vanish without trace.
lib/ui/tile.dart 819 Yes — nine force-unwraps, all inside build(). Gap. S5-F9. The invariants they depend on are enforced by Engine.reconcile (proven by test), so nothing throws today; but 819 lines with no try, no errorBuilder, and 0.00% coverage, rendering the one thing the product exists to render, is the least-defended surface in the codebase.

4. late and ! sites — does the invariant actually hold?

Every late site and every ! site whose failure would be unrecoverable, checked against a real sequence of events rather than an inspection.

Site Invariant claimed Holds?
home.dart:34, :35, :44 (late final engine, i18n, alarmVol) Assigned in initState before any read Yes. Assigned at :74, :84, :85; build() cannot run before initState returns, and _boot() (called at :86) is the first async user. Nothing between :74 and :85 reads i18nstore.load/seedIfFresh/repairGeneratedPhrases/migrateZoneSounds are all store-only.
tile.dart:65-70 (six late final AnimationController) Assigned in initState, disposed in dispose Yes. Created :75-85, all six disposed :147-152.
modals.dart:193-198 (late editor fields) Assigned in initState Yes. All six assigned unconditionally at :204-221; both branches of the if (t != null && t.isChain) set mode, steps, min, sec.
modals.dart:610 (late double vol = …) Lazy initialiser Yes — a late with an initialiser cannot be read before assignment.
main.dart:48 (child!) MaterialApp.builder receives a non-null child Yeshome: is set (:55), so the builder always wraps a route.
store.dart:160 (v!) Guarded by v == 'fr' \|\| v == 'en' on the same line Yes.
i18n.dart:143, :145 ((… ?? _strings['en'])!) The 'en' map is a const literal Yes.
journal.dart:170, :226, :230 (_file!) Guarded at :164 / :208, but with an await between guard and use Contained, not held. _file can in principle be nulled between the guard and the use (only by init's catch or disableForTests). All three uses sit inside a try (:169, :213), so the resulting NoSuchMethodError is caught rather than escaping. Safe, though for the wrong reason.
engine.dart:266 (run[t.id]! in _fireAlarm) Caller checked non-null Yestick checks r == null at :304 and t is the same key.
engine.dart:312, :318, :320, :324 (r.endsAt!) status == runningendsAt != null Yes — enforced by reconcile (:84), re-established by startTimer (:177, :182), adjustTimer (:232), resumeTimer (:258), and cleared only together with the status change in _fireAlarm (:280-281) and pauseTimer (:247-248). Demonstrated by test.
engine.dart:88, :317, :320, :323 (def.steps! / t.steps!) r.chaindef.isChainsteps != null && length >= 2 Yes — enforced by reconcile (:86-89); saveDef clears the run entry (:378) before it can reshape a definition. Demonstrated by test.
engine.dart:389, :396 (durationSec!) Non-null on the steps == null branch Yes:372-374 assigns durationSec = 5 whenever steps == null && (durationSec == null \|\| durationSec < 5).
engine.dart:335 (r.nextVoiceAt!) Guarded by r.nextVoiceAt != null at :334 Yes.
alarm_backstop.dart:107, :109, :110, :273 Guarded at :104, :108, :273 respectively Yes, all four guards are on the immediately preceding lines.
home.dart:126, :127 (r.endsAt!, t.steps!.length in _boot) Same run/def invariant Yesstore.load (:75) calls reconcile (store.dart:86) before _boot runs (:86). This is the one place a violation would throw outside any handler, from an unawaited async method — so the invariant is load-bearing, and it holds.
home.dart:432 (t!.name) res.delete == true ⇒ the editor was opened on an existing timer Yes — the delete button is only rendered inside the if (widget.existing != null) branch, and _openEditor passes that same t.
home.dart:534 (_dragId!) Guarded at :533 Yes.
tile.dart:186-201, :364, :498-501, :731 Run/def invariant, plus dragOffset != null at :363 and sign != null Yes today, by reconcile only — see S5-F9.

No late/! site in lib/ has a demonstrable production sequence that breaks it. That is a real, positive result and it deserves saying: the null-safety discipline in this codebase is sound. The exposure is not that an invariant is wrong, it is that when one eventually is, the codebase has nowhere to report it (S5-F2) and one handler that responds destructively (S5-F1).


5. Coverage manifest

Every file below was read end to end in the working copy at commit 03a176e.

5.1 lib/ — 18 files, 4,853 lines (complete)

# File Lines What I checked
1 lib/main.dart 58 The only place global handlers can be installed — confirmed absent (S5-F2). The .then().catchError() wakelock chain at :28-32 as the codebase's reference pattern, and the unhandled SystemChrome future three lines later (S5-F8). child! at :48.
2 lib/diagnostics.dart 54 Zero error handling — proven correct and load-bearing (§3). Enumerated all 36 Diag.fail and 6 Diag.clearCritical call sites across lib/ and cross-checked every scope against the banner map (S5-F5).
3 lib/journal.dart 250 All 6 catch sites classified; 3 findings (S5-F3, F4, F10). Timer lifecycle (:106-107 created, cancelled only in disableForTests). The _chain serialisation at :163-180. _file! at :170, :226, :230 — containment verified. Unguarded _prefs?.setInt at :152.
4 lib/alarm_backstop.dart 279 All 6 catch sites — every one CORRECT, including the only on ArgumentError and the only e is PlatformException && e.code discriminator in the codebase. The recursive degrade-and-retry at :216. Four force-unwraps, all guarded on the preceding line.
5 lib/audio/audio.dart 116 Both catch sites — CORRECT. Three unawaited platform futures (:99, :107, :114) with no handler (S5-F8). The p == null reporting path at :63-67. No force-unwraps.
6 lib/audio/voice.dart 204 All 6 catch sites — CORRECT. The discarded native booleans at :134 and :166-168 (S5-F7). Four unawaited _drain() futures whose tail at :174-177 is outside the inner try (S5-F8). The _gen cancellation guard at :174.
7 lib/audio/alarm_volume.dart 68 Zero error handling — proven correct (§3). sane() NaN/Infinity handling at :44-45. Traced the guarantee at :14-16 through home.dart:209-218 to MainActivity.kt:57 and found it defeated (S5-F7).
8 lib/engine/engine.dart 432 The single catch at :338 — the stream's most serious finding (S5-F1), with a forced-fire test and a mutation. reconcile's invariant enforcement at :81-91, verified by test as the guarantee that makes 20 force-unwraps across three files safe. All 14 force-unwraps traced to their guards (§4).
9 lib/engine/models.dart 160 Zero error handling — proven correct by contract with store.dart's per-entry salvage (§3). Traced all eight unguarded casts in the four fromJson factories to their wrapping handlers.
10 lib/engine/store.dart 354 All 12 catch sites — 11 CORRECT, 1 raising an unmappable critical scope (S5-F5). The _preserveCorrupt design at :115-128 as the best handler in the codebase. _write/_guard async patterns. v! at :160.
11 lib/i18n.dart 167 Zero error handling — proven correct (§3). The two ! sites at :143/:145 against the const 'en' map. The six banner-message keys the map at home.dart:675-687 depends on.
12 lib/ui/home.dart 722 All 3 catch sites. The banner map at :670-704 cross-checked against all 12 critical scopes (S5-F5). Six unawaited futures (S5-F8), incl. the .then with no .catchError at :141. All 10 force-unwraps traced (§4), incl. :126-127 — the only invariant violation that would throw outside any handler. dispose() completeness at :236-243.
13 lib/ui/modals.dart 746 The single catch at :712 and the early return at :702 that bypasses it (S5-F6). All 7 late fields against initState at :201-222. Controller disposal at :224-228. t.steps! at :211. The delete-button guard that makes home.dart:432's t! safe.
14 lib/ui/tile.dart 819 Zero error handling — the one genuine gap (S5-F9). All 9 force-unwraps in build() traced to reconcile. initState/didUpdateWidget mounted guards at :90, :109. All six controllers created and disposed. shouldRepaint on both painters.
15 lib/ui/grid_layout.dart 109 Zero error handling — proven correct (§3). Negative-geometry clamps at :91-94; the zero-timer case. 100% covered.
16 lib/ui/theme.dart 82 Zero error handling — correct today, one LOW note on the unguarded s.round() in fmtTime at :78 (§3). fillFor NaN behaviour.
17 lib/ui/header.dart 215 Zero error handling — proven correct, nothing in it can throw (§3).
18 lib/ui/logo.dart 18 Zero error handling — correct today; one LOW note on the missing errorBuilder (§3). Asset presence confirmed against pubspec.yaml:44-46.
Total 4,853

5.2 Platform layer (error-handling posture only; S3 owns the depth)

File Lines What I checked
android/.../MainActivity.kt 176 All 8 catch (_: Exception) sites classified (§2). Proved by grep that result.error(...) appears zero times, which is the cross-cutting fact: no native failure can reach Dart as an error. Traced K1 and K7 to their Dart consumers (S5-F7).
ios/Runner/AppDelegate.swift 207 The one real do/catch at :140-152 and its result(false) — the only native error path in the project that Dart actually checks (voice.dart:48). The deliberate result(nil) volume answers at :54-59. All four guard … else { result(false) } early returns.

5.3 Proof artefacts written

All under proof/01_findings/S5/, each stamped by run_and_record.sh with command, cwd, UTC time, Flutter version, git sha and exit code (R12).

File Contents
s5_error_handling_test.dart The 12 forced-failure tests, as run.
01_s5_tests_baseline.txt All 12 green against unmodified production code. EXIT_CODE=0.
02_no_global_handlers.txt Grep proving no FlutterError.onError / PlatformDispatcher.onError / runZonedGuarded / ErrorWidget.builder. EXIT_CODE=1 (no matches).
03_no_crash_reporting.txt Grep proving no crash-reporting or telemetry package anywhere. EXIT_CODE=1.
04_catch_sites.txt All 38 lib/ catch sites, confirming the baseline count.
05_diag_fail_sites.txt All 36 Diag.fail + 6 Diag.clearCritical sites with scopes — the input to S5-F5.
06_unawaited_and_timeout.txt Every .then(, Timer(, .timeout( and unawaited( in lib/ — the input to S5-F8.
12_kotlin_no_error_path.txt Grep proving MainActivity.kt contains zero result.error(...) calls. EXIT_CODE=1.
07_mutationA.patch / 07_mutationA_result.txt R8 mutation for S5-F1: catch (_)rethrow makes the named test red. EXIT_CODE=1.
08_mutationB.patch / 08_mutationB_result.txt R8 mutation for S5-F3: removing the writeAsString('') makes the named test red. EXIT_CODE=1.
09_restored_green.txt Both mutations reverted; all 12 green again. EXIT_CODE=0.
10_flutter_release_errorwidget.txt Verbatim Flutter 3.44.8 SDK source proving the release grey box Color(0xF0C0C0C0).
11_flutter_error_ondefault.txt Verbatim SDK source proving FlutterError.onError = presentError is the default.

5.4 What I checked and found nothing in

  • StreamController / stream subscription leaks: zero instances in lib/ (code map §3.4, grep StreamController\|\.listen( → no output). Nothing to find.
  • // ignore: suppressions hiding analyzer errors: zero (baseline). No suppressed diagnostic is masking an error-handling defect.
  • skip:ped tests hiding a known-failing error path: zero (baseline).
  • assert() used as production error handling: grep found none in lib/ outside the Flutter SDK — correct, since asserts are stripped in release.
  • A late or ! site whose invariant I could break with a real sequence: none (§4). I looked specifically for a persisted-state shape that survives reconcile and then throws, and could not construct one.

6. Reproduction

cd a scratch working copy
cp proof/01_findings/S5/s5_error_handling_test.dart test/
flutter test test/s5_error_handling_test.dart

The working copy is an rsync of the subject repo at 03a176e minus build/ and .dart_tool/; git rev-parse HEAD in the copy returns 03a176e72ef0075eec86b8915cbe6e93042a3b9d. The subject repo at the app repository was never written to.

S5 refutation — error-handling disciplineagent_reports/S5_refute.md · raw .md

S5 refutation — error-handling discipline

Refuter, fresh context. I did not write findings/S5_error_handling.md. Governing rule: R5. Subject read-only at 03a176e72ef0075eec86b8915cbe6e93042a3b9d; every experiment on my own copy at a scratch working copy (R10). Every command recorded through proof/run_and_record.sh [not published] into proof/01_findings/S5_refute/ (R12).

Headline: 9 of 10 findings CONFIRMED as code defects, 1 partially REFUTED (S5-F7), the 48/34/11/3 census REFUTED (it is 47/33/11/3), both mutation proofs REFUTED as R8-non-compliant, and 11 of S5's 12 proof files carry no TREE_STATE header at all. Both absence claims survive my independent re-derivation: there is no global error handler and no crash reporting, and I found none S5 missed.


1. Finding-by-finding

id claim in brief verdict reasoning severity
S5-F1 Engine.tick's catch (_) at engine.dart:338 deletes the ringing timer and reports nothing CONFIRMED (defect), REFUTED (severity + mutation) Location and verbatim quote are exact — I read engine.dart:305-343 myself; the try is at :305, the handler at :338-343, and it does run.remove(t.id); host.persistRun(); with no report. But it is not reachable in production. The only throw sources inside the try are the three host callbacks; I read _HomeScreenState.onAlarmFire (home.dart:284-303), onAlarmRepeat (:306-312) and onStepAdvance (:314-334) in full and every plugin call in them (sounds.ringtone, sounds.stepChime, Vibration.vibrate, HapticFeedback.lightImpact) is either async (a synchronous throw is impossible) or guarded; every force-unwrap resolves through Engine.reconcile (engine.dart:74-100), and I verified the clone path is consistent — _defFor (:103-112) resolves a clone to its parent and viewList (:116-125) hands out t.copyWithId(c.id) of that same parent, so t.steps and the reconciled def.steps are the same object. S5 concedes this in its own "Why not BLOCKER" note. A defect that fires only under an injected ThrowingHost is defence-in-depth, not "wrong behaviour during service" — that is R13 MEDIUM, not HIGH. Two further problems: (a) S5 does not mention that test/robustness_test.dart:149 (tick survives a hand-corrupted entry — later timers still fire) is a pre-existing repo test that enshrines this swallow-and-drop as desired behaviour — relevant to any fix; (b) the proposed fix, "add a nullable failure callback to EngineHostvoid onEngineFault(String id, Object e)", is not implementable as written: EngineHost (engine.dart:10-23) is an abstract interface with implementers in lib/ and in the test suite, so an abstract member breaks all of them — it needs a concrete empty default body. HIGH → MEDIUM
S5-F2 No FlutterError.onError / PlatformDispatcher.instance.onError / runZonedGuarded / ErrorWidget.builder, and no crash reporting CONFIRMED Independently re-derived by a different method (§3). Both absences hold. The two Flutter SDK quotes are accurate against the pinned 3.44.8 install. One evidence defect (R2): the finding states that proof/01_findings/S5/03_no_crash_reporting.txt returned "no output, EXIT_CODE=1". The file actually records Binary file android/.gradle/9.1.0/executionHistory/executionHistory.bin matches and EXIT_CODE=0. The conclusion is still right (that is a Gradle build-cache binary, not app code) but the finding misreports its own artifact. HIGH — agreed
S5-F3 Journal._rotate (journal.dart:200-202) erases the whole journal when it cannot read it CONFIRMED (defect), REFUTED (severity) Verbatim quote of journal.dart:192-203 is exact. But S5 never establishes that the trigger is attainable. _rotate is called from exactly one place — journal.dart:72, if (await f.length() > _maxBytes) await _rotate(f), _maxBytes = 3 * 1024 * 1024. I proved by test (R-F2, proof/01_findings/S5_refute/R16_refuter_tests.txt) that a journal with a malformed UTF-8 tail under 3 MB passes through init completely untouched: 25 bytes in, 197 bytes out, original line intact. The code's own comment (journal.dart:42-48) records a real measured service journal of 361 lines; at that rate the 3 MB threshold is on the order of a hundred-plus services away. It fires at boot, destroys diagnostic data rather than user data, and changes no service behaviour → R13 MEDIUM ("a degraded path"), not HIGH. It also fully duplicates S2-F6 (findings/S2_persistence.md:303-312, same lines, same severity) — S2 owned journal.dart depth, so this is duplicated effort, not a boundary gap. HIGH → MEDIUM
S5-F4 Journal write failures go to debugPrint only, and Journal.ready keeps returning true CONFIRMED, incomplete journal.dart:169-177 verbatim exact; ready => _file != null (:53) confirmed, and the :175 catch does not null _file, so the Settings block at modals.dart:673 stays up. But the finding stops at reporting and misses the data loss in the same six lines: _buf.clear() at :168 runs before the write, so a failed flush discards those lines for good. S5's own proposed fix — "stamp !! ECRITURE JOURNAL IMPOSSIBLE into the buffer for the next successful flush" — is not implementable as written, because by the time the handler runs there is no buffer left holding the lost lines. I proved the loss by test (§4). S2 already owns this half (findings/S2_persistence.md:260-296), so it is a gap in S5's classification, not in the audit. MEDIUM — agreed
S5-F5 store.dart:270 raises migrate-zone-sound critical; home.dart:675-687 maps it to nothing CONFIRMED Both halves verbatim-exact — I read home.dart:670-704 and store.dart:253-277. I enumerated every Diag.fail(..., isCritical: true) site myself (proof/01_findings/S5_refute/R05_critical_scopes.txt): audio-init, backstop-notif, backstop-init, backstop-exact, backstop-schedule, wakelock, audio-play, voice-init, load-$key, save-$key, migrate-zone-sound. That is 11 distinct critical scopes, not the 12 S5 claims, and exactly one — migrate-zone-sound — matches none of the six startsWith prefixes, so msgs is empty and home.dart:689 returns SizedBox.shrink(). Substance right, count off by one. The else catch-all fix is correct and within R6. MEDIUM — agreed
S5-F6 modals.dart:702's early return bypasses the only Diag.fail, and exportCopy returns null after a bare debugPrint CONFIRMED modals.dart:698-717 and journal.dart:233-236 verbatim-exact. The return at :702 is inside the try, so the catch at :712 cannot see it, while the finally at :714 resets _sending — the button really does flicker and do nothing. Reachable without contrivance (a failing getTemporaryDirectory() or a failing copy). Two-line fix, correct, no new feature. MEDIUM — agreed
S5-F7 The native side can never report an error, so every Dart .catchError on the two channels is unreachable; and Dart discards the booleans it does return PARTIALLY REFUTED Two halves, one right, one wrong. Right: MainActivity.kt contains zero result.error(...) calls (my own sweep, R06, found no FlutterError/result.error anywhere), all eight catch (_: Exception) sites are verbatim as quoted, and the discarded booleans are real and production-reachable — stronger than S5 argues: 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; voice.dart:166-168 awaits and throws all of that away, after voice.dart:165 has already written parole "…" to the journal. Wrong: the load-bearing sentence "There is therefore no code path by which a native failure can arrive on the Dart side as an error, which makes every Dart-side .catchError on cadence/volume and cadence/tts unreachable for native faults" is false. Flutter's own embedding converts any uncaught RuntimeException from a channel handler into a PlatformException delivered to Dart — verbatim, MethodChannel.java:285-290 of the pinned 3.44.8 SDK (proof/01_findings/S5_refute/R14_flutter_methodchannel_runtimecatch.txt): } catch (RuntimeException e) { … reply.reply(codec.encodeErrorEnvelopeWithStacktrace("error", e.getMessage(), …)); }. MainActivity.kt has unguarded throw sites outside its try blocks — getStreamMaxVolume at :45 (runs on every volume call), (call.arguments as Number).toDouble() at :55, call.arguments as String at :83, call.arguments as Map<*, *> / (args["volume"] as Number) at :101-102 — and else -> result.notImplemented() at :61/:109 reaches Dart as a MissingPluginException. So home.dart:211's .catchError and home.dart:227's catch are reachable, and the claim that eight swallowed sites make the whole boundary error-proof is an over-generalisation from eight specific sites to the channel as a whole. MEDIUM — agreed for the surviving half
S5-F8 Eleven unawaited futures with no destination for a rejection CONFIRMED (substance), REFUTED (proof) I checked all eleven cited sites and all eleven exist as described (main.dart:33, home.dart:141-144, :202, audio.dart:99, :107, :114, voice.dart:63, :146, :177, :201, journal.dart:152). But the cited artifact does not demonstrate the claim. proof/01_findings/S5/06_unawaited_and_timeout.txt is a grep for unawaited(\|\.timeout(\|\.then(\|Timer(, and 8 of the 11 sites do not appear in itmain.dart:33, home.dart:202, audio.dart:99/107/114, voice.dart:63, :146, journal.dart:152 are all absent, because none of them matches that pattern. Calling it "the full inventory" is an R2 defect. Two smaller problems: the sentence "That pattern is applied at exactly four sites in lib/" is wrong on its face (the parenthetical that follows lists six), and the inventory is incomplete — it omits home.dart:86 _boot() (see §4) and the Timer callbacks at home.dart:277, home.dart:387 and alarm_backstop.dart:147. MEDIUM — agreed
S5-F9 tile.dart: 819 lines, no error handling, 0.00% coverage, nine force-unwraps in build() CONFIRMED, thin Verified independently: 819 lines, zero try/catch (grep -cE '\btry\b\|\bcatch\b' lib/ui/tile.dart → 0), nine force-unwraps at :186, :187, :193, :200, :364, :498, :500, :501, :731, all inside a build or a widget-builder helper reached from one. The quoted block is verbatim-correct (the header says :184-201 but the four quoted lines are 185-188 — cosmetic). S5's own text concedes "this is a gap in defence, not a live defect", and I independently confirmed the reconcile invariant holds. The load-bearing content of the finding — 0.00% coverage — is a baseline fact already owned elsewhere, so this partly double-counts. MEDIUM survives as maintainability only. MEDIUM — agreed, as maintainability
S5-F10 Two empty catch (_) {} at journal.dart:128 and :231; the first degrades Journal.device CONFIRMED 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 about _file not yet being writable is wrong in detail (_file is assigned at :73, before device at :75, so Journal.log would in fact be admitted by the :134 guard) but the recommended debugPrint + suffixed return string is fine anyway. LOW — agreed

2. Independently derived counts

I did not re-run S5's grep. I counted the catch keyword with word boundaries, the bare on <Type> clauses, and .catchError( separately, then subtracted comment hits by reading each one.

proof/01_findings/S5_refute/R01_all_catch_tokens_lib.txt, R02_catcherror_sites_lib.txt, R03_platform_catch_sites.txt, R04_bare_on_clauses.txt.

layer derivation count
lib/catch (…) clauses 35 \bcatch\b tokens, minus 4 that are prose inside comments (journal.dart:11, home.dart:173, voice.dart:195, engine.dart:314 — I opened all four) 31
lib/ — bare on <Type> { with no catch voice.dart:169 (} on TimeoutException {) 1
lib/.catchError( callbacks main.dart:30, home.dart:211, store.dart:141, store.dart:177, voice.dart:196, alarm_backstop.dart:230 6
lib/ total 38 ✓ matches S5 and the AGENT_RULES baseline
MainActivity.kt :58, :79, :87, :92, :97, :105, :151, :172 8
AppDelegate.swift :149 — the only catch in the file 1 ✗ S5 counts 2
combined 47, not 48

The 48 is wrong. S5's platform table row S2 is AppDelegate.swift:54-59 — result(nil) for both volume methods, **no catch** — CORRECT. Its own cell says there is no catch there, yet it is carried into the total as a catch site and into the CORRECT bucket. Corrected classification, keeping every one of S5's own labels for the 47 real sites:

CORRECT 33, SWALLOWED 11, WRONG 3 — not 34/11/3.

Two further census defects worth recording:

  • proof/01_findings/S5/04_catch_sites.txt, cited as "confirming the baseline count", contains a different set of 38 from the classification table: it includes voice.dart:195, which is a comment line matched on the word catchError inside it, and omits the real on TimeoutException { at voice.dart:169. Two errors that happen to cancel. The artifact does not establish the table.
  • "Of the 12 distinct critical scopes emitted across lib/" (S5-F5) is 11 — enumerated in R05_critical_scopes.txt and listed in the S5-F5 row above.

Mutation proofs — both fail R8

R8 requires (a) a whole-suite run under the mutation captured with --reporter=json whose failing set equals exactly the named test, (b) result "failure" not "error", (c) a distinct patch per test, (d) git status --porcelain empty after revert. S5 ran flutter test <one file> --plain-name '…' for both mutations — one file, one test, no JSON reporter — and recorded no post-revert git status anywhere. I re-ran both properly on my copy.

S5's claim what a whole-suite --reporter=json run actually shows
Mutation A (engine.dart:338rethrow) "turns that named test red" 3 tests fail, not 1S5-F1 … onAlarmFire, S5-F1 … onStepAdvance, and the pre-existing repo test run invariants (audit F12) tick survives a hand-corrupted entry — later timers still fire (test/robustness_test.dart:149). All three results are "error", not "failure". R8(a) and R8(b) both violated. proof/01_findings/S5_refute/R09_mutationA_wholesuite_json.txt
Mutation B (journal.dart:201 writeAsString('') removed) "turns that named test red" Failing set is exactly 1 test ✓, but the result is "error" — the test throws FileSystemException from its own readAsStringSync, it does not fail an expect. R8(b) violated. proof/01_findings/S5_refute/R11_mutationB_wholesuite_json.txt

Post-revert git status --porcelain recorded for both (R10, R12): no tracked path modified.

Proof integrity — TREE_STATE

run_and_record.sh stamps TREE_STATE: CLEAN|DIRTY precisely so a run made under an unreverted mutation cannot masquerade as a clean one. 11 of S5's 12 proof files carry no TREE_STATE line at all — only 12_kotlin_no_error_path.txt, recorded nine minutes after the rest, has it:

01_s5_tests_baseline.txt                 0     06_unawaited_and_timeout.txt       0
02_no_global_handlers.txt                0     07_mutationA_result.txt           0
03_no_crash_reporting.txt                0     08_mutationB_result.txt           0
04_catch_sites.txt                       0     09_restored_green.txt             0
05_diag_fail_sites.txt                   0     10_flutter_release_errorwidget.txt 0
                                               11_flutter_error_ondefault.txt    0
12_kotlin_no_error_path.txt              1

They were recorded with the pre-header version of the script. This does not prove those runs were dirty; it proves they cannot be shown to have been clean, and that includes both mutation results (07, 08) and the restored-green run (09) — exactly the three files where an unreverted mutation would be invisible. Every file I produced carries TREE_STATE: CLEAN.


3. The two absence claims, re-derived by my own method

I deliberately did not re-run S5's greps.

No global error handler. I swept every tracked file in the repository (git ls-files | xargs grep) for onError, runZoned, Zone., ErrorWidget, FlutterError, PlatformDispatcher, reportError, onPlatformError, errorBuilder, ensureInitialized (proof/01_findings/S5_refute/R06_onerror_zone_tokens_tracked.txt). Total hits outside test bindings: four, none of them a handler — MainActivity.kt:132 and :133 are UtteranceProgressListener TTS callbacks; store.dart:6 imports PlatformDispatcher and store.dart:347 uses it only for .locale.languageCode; main.dart:23 is WidgetsFlutterBinding.ensureInitialized(). No FlutterError, no runZonedGuarded, no ErrorWidget.builder, no errorBuilder anywhere. Confirmed. I found no handler S5 missed.

No crash reporting. Instead of a keyword grep I read the dependency manifests and the native configuration directly (R07_pubspec_deps.txt, R08_native_reporter_config.txt): pubspec.yaml declares 9 packages plus the Flutter SDK, none a reporter; the full transitive closure in pubspec.lock (108 packages, enumerated in the artifact) contains no crashlytics/sentry/firebase/ bugsnag/appcenter/datadog/rollbar/posthog/amplitude/mixpanel; git ls-files matches no google-services.json, GoogleService-Info.plist or Crashlytics file; android/build.gradle.kts + android/app/build.gradle.kts declare exactly two plugins (com.android.application, dev.flutter.flutter-gradle-plugin); AndroidManifest.xml has no analytics meta-data; there is no ios/Podfile. Confirmed. Nothing S5 missed.


4. Finding S5 missed

One, held to R2. A second candidate is recorded below and explicitly not claimed, because another stream already owns it.

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

  • Severity: MEDIUM (see the trigger note — the consequence class is BLOCKER-grade)
  • Location: lib/ui/home.dart:86 (the call), lib/ui/home.dart:154 (the ticker), valid at 03a176e
  • What is wrong: initState fires _boot(); with no await, no .catchError and no enclosing try. _boot is a 60-line async method containing four awaits, and _ticker — the Timer.periodic that drives every countdown, every alarm and every repeat in the app — is created by its last statement. Any throw anywhere earlier in _boot therefore skips the ticker permanently: the board renders, the tiles show their stored state, and nothing ever counts down or rings again. Because there is no PlatformDispatcher.instance.onError (S5-F2), the rejected future has no destination at all — no banner, no Diag entry, no journal line. The app is dead and certifies itself healthy.
  • Evidence: 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.
  • Why S5 missed it: S5 examined the site — its §4 table row for home.dart:126, :127 says "This is the one place a violation would throw outside any handler, from an unawaited async method — so the invariant is load-bearing, and it holds" — and then stopped at "it holds". It never states what happens if it does not, never lists _boot() among F8's eleven unawaited futures, and its F8 fix ("the global handler in S5-F2 fixes the whole class") would only report this failure, never recover from it: reporting does not start a ticker.
  • Why it matters for a restaurant kitchen: every tile on the board freezes at the value it was restored with. A cook glancing at the board sees plausible numbers that never change and never ring. This is the one failure mode where the app looks more healthy than a grey box does.
  • Proposed fix: 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 the fix: 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.
  • Trigger note (why MEDIUM, not BLOCKER): I applied the same standard I used to downgrade S5-F1. I could not construct a production sequence that throws at home.dart:126-127: Store.load (store.dart:86) calls reconcile, and nothing between initState:75 and the loop at :121 reshapes a definition — seedIfFresh only adds timers to a fresh install, repairGeneratedPhrases touches phrase, migrateZoneSounds touches sound. The exact artefact that flips this to BLOCKER: any persisted cadence-run-v1 shape that survives Engine.reconcile and then violates the run/def relationship — most plausibly introduced by a future field added to RunEntry without a matching reconcile clause.

Recorded, not claimed — journal.dart:167-177 loses buffered lines on a transient write failure

I proved this one before checking ownership, and it is real: _buf.clear() at :168 runs before the write at :170, so a flush that fails discards its lines permanently while Journal.ready stays true and the log reads as complete. Test R-F1 (proof/01_findings/S5_refute/R16_refuter_tests.txt) forces one write to fail and the next to succeed; the recorded output is a journal containing the session header and the later alarm, with the two lines written during the failed flush simply absent and no marker:

S5R: journal after a transient write failure:

================================================
SESSION 2026-08-04 12:40:24.665  ·  Cadence vrefute-test
APPAREIL macos
================================================
12:40:24.680 ALARME  Poulet sonne — decalage 8 ms

ALARME Frites and parole "The fries are ready" are gone for good; Diag.log is empty. Mutation C (clear the buffer only on a successful write, R17_mutationC.patch) is fully R8-compliant — whole suite, --reporter=json, failing set exactly R-F1, result "failure" not "error", patch distinct, git status --porcelain after revert clean of tracked paths (R18, R19).

But S2 already owns itfindings/S2_persistence.md:260-296, same file, same lines, with the correct _buf.insertAll(0, chunk) fix. So it is a gap in S5's classification (S5 labels journal.dart:175 SWALLOWED on reporting grounds and never notices the data loss; its S5-F4 fix is not implementable because of it) and not a finding missed by the audit. Claiming it as a contribution would be double-counting.

Boundaries: did anything fall between S2, S3 and S5?

Nothing is unowned by PLAN.md:151-164; the exposure is deferral to streams that have not landed. S5 defers the per-site Kotlin depth to S3 three times ("S3 owns that", "S3 owns the depth"). As of this review proof/01_findings/S3/ is active but findings/S3_*.md does not exist, and the same is true of S4, which owns lib/ui/* — the file S5-F9 defers most of its substance to. The overlap runs the other way too: S5-F3 fully duplicates S2-F6 and S5-F4 half-duplicates an S2 finding, both in files S2 owns. Recommendation for the coordinator: treat S5's platform section as a pattern-level placeholder only until S3 lands, and de-duplicate S5-F3/S5-F4 against S2-F5/S2-F6 before the report.


5. Per-file coverage manifest

Every file below opened in my own copy at 03a176e. R5 accepts findings or a manifest; this is supplied in addition.

# File Lines What I checked
1 lib/main.dart 58 Read in full. Confirmed main() installs none of the four global handlers (S5-F2) and that main.dart is the only place they could go. Verified the .then().catchError() chain at :28-32 and the unhandled SystemChrome future at :33. child! at :48home: is set at :55, holds.
2 lib/diagnostics.dart 54 Read in full. Verified Diag.fail cannot throw — with one correction to S5's reasoning: critical.value = {…} at :33 is not an inert assignment, it notifies listeners synchronously, and a ValueListenableBuilder listener calling setState during a frame would throw. I grepped every Diag.fail call site and none is inside a build, so the conclusion holds but S5's stated reason ("ValueNotifier assignment cannot throw") is incomplete. Enumerated all Diag.fail sites myself from R05: 35, plus 6 Diag.clearCritical = 41, which matches the code map's independently derived 41.
3 lib/journal.dart 250 Read in full. All 6 catch sites re-derived and re-classified. _rotate reachability tested (R-F2). _flush buffer-clear-before-write tested (R-F1) and mutated (R17/R18). ready semantics at :53 vs the :175 handler. _file! at :170/:226/:230 — all inside a try, containment holds. _prefs?.setInt at :152 unguarded. Confirmed _rotate's only caller is :72 behind the 3 MB gate.
4 lib/alarm_backstop.dart 279 Read :66-120 and :175-233 in full, scanned the rest. All 6 catch sites re-checked. Confirmed init (:70-96) wraps its body end to end, so _boot cannot be broken by it. Confirmed the recursion at :216 is bounded — the retry requires denied && _exactOk, and _exactOk is set false at :213 before recursing, so at most one retry. _desired (:100-116) guards both endsAt and steps.
5 lib/audio/audio.dart 116 Read :40-116 in full. Both catch sites. Attacked and dismissed a candidate finding of my own: I expected audio-play to pin the banner permanently after one transient failure, but :74 does call Diag.clearCritical('audio-play') on a successful play, matching the store.dart:139 pattern. Confirmed the three unawaited platform futures at :99, :107, :114. Noted init's _newPlayer() loop at :45-48 sits outside the try, but its only caller wraps it (home.dart:135-140).
6 lib/audio/voice.dart 204 Read :44-204 in full. All 6 catch sites, including the only bare on TimeoutException { in the codebase at :169 — which S5's own census artifact misses. Attacked and dismissed a second candidate of my own: the early return at :174 that skips _speaking = false is correct, not a leak — when it fires, _speaking is already owned by a newer _drain generation or was reset by stopFor at :199. Confirmed Diag.clearCritical('voice-init') at :58. Confirmed the discarded booleans at :134 and :166-168 and the journal line at :165 written before the call.
7 lib/audio/alarm_volume.dart 68 Read in full. Zero error handling, correct: sane() handles non-finite at :44-45, and the injected apply is home.dart:209-218, which is guarded. Traced the :14-16 guarantee through to MainActivity.kt:57 — defeated for a swallowed setStreamVolume, per the surviving half of S5-F7.
8 lib/engine/engine.dart 432 Read :74-145 and :295-365 in full. The single catch at :338 verbatim. Verified reconcile (:74-100) enforces every invariant S5 relies on, and closed the gap S5 left open by checking that _defFor (:103-112) and viewList (:116-125) resolve a clone to the same parent def — the only way a clone could pass reconcile and then throw. It cannot. Confirmed engine.dart:314's "catch" is prose in a comment, not a site.
9 lib/engine/models.dart 160 Checked via the code map's cast inventory plus the wrapping handlers at store.dart:72-76 and :98-102. Throw-by-contract with per-entry salvage one layer up — S5's classification stands.
10 lib/engine/store.dart 354 Read :253-277 in full; all 12 catch sites cross-checked against my own census. Enumerated every isCritical: true site and found 11 distinct critical scopes, not 12. Confirmed migrate-zone-sound at :270 is the only one matching no banner prefix. Confirmed store.dart:139 clears its own critical scope on success — the pattern audio follows and voice-init follows.
11 lib/i18n.dart 167 Checked the two ! sites against the const 'en' map and the six banner-message keys the home.dart:675-687 chain depends on. Nothing throws; a missing key returns the key.
12 lib/ui/home.dart 722 Read :60-160, :160-260, :255-335, :655-721 in full. All 3 catch sites. Re-derived the banner map at :670-704 against all 11 critical scopes. Traced onAlarmFire/onAlarmRepeat/onStepAdvance for any synchronous throw reachable from Engine.tick — none. Produced R5-F1 from :86 + :154 + :238. Confirmed home.dart:173's "catch" is prose in a comment.
13 lib/ui/modals.dart 746 Read :665-720 in full. Confirmed the single catch at :712, the return at :702 inside the try, the finally at :714, and the if (Journal.ready) gate at :673 that S5-F4 depends on.
14 lib/ui/tile.dart 819 Verified zero try/catch by my own grep, all nine force-unwraps and their enclosing scopes (_buildTile, _banner, _CtlBtn.build), and read :180-205 verbatim against S5's quote. Traced each unwrap to the reconcile invariant.
15 lib/ui/grid_layout.dart 109 Checked for error handling and unguarded arithmetic: none; negative geometry clamped at :91-94. Nothing to find.
16 lib/ui/theme.dart 82 Checked fmtTime's unguarded s.round() at :78 against its call sites in tile.dart (:189, :196, :200), which feed it integer-millisecond arithmetic — always finite. S5's LOW note is fair.
17 lib/ui/header.dart 215 Checked for async, platform calls, force-unwraps and error handling: none. Nothing can throw. Confirmed.
18 lib/ui/logo.dart 18 Confirmed Image.asset with no errorBuilder at :13, and that a decode failure would route to the default FlutterError.onError — i.e. the console (S5-F2). Asset declared at pubspec.yaml. S5's LOW note is fair.
P1 android/.../MainActivity.kt 176 Read :40-176 in full. All 8 catch sites re-derived independently. Found the unguarded throw sites S5's "no error path" claim overlooks:45, :55, :83, :101-102 — and the notImplemented() branches at :61/:109, then verified against the Flutter SDK's own MethodChannel.java:285-290 that these do reach Dart as errors. Confirmed the UtteranceProgressListener wiring at :128-135 and the single-reply discipline in completeSpeak/completeAllSpeaks (:160-169), which is correct.
P2 ios/Runner/AppDelegate.swift 207 Grepped every do { / try / catch: exactly one catch, at :149. Confirmed S5's S2 table row describes code with no catch in it and must not be counted in the census.

6. Judgement on S5's verdict

S5's verdict — "No. This codebase does not handle errors correctly." — stands, but the headline overstates what the evidence supports.

What the evidence actually supports: of 47 catch sites, 33 are correct, and the Dart handlers route into a single reporting choke point — Diag.fail, called from 35 sites across lib/ (R05) — that logs, persists to the flight recorder and raises an operator banner. That is a genuinely disciplined anticipated-error posture, and I could not break it — I attacked three of S5's CORRECT classifications (audio.dart:74, voice.dart:174, journal.dart:231) expecting to find a swallowed failure behind each, and all three held. S5 says as much in its own §0 and then writes a verdict whose plain reading contradicts it.

The verdict survives on one pillar, not three:

  • S5-F2 carries it. No FlutterError.onError, no PlatformDispatcher.instance.onError, no runZonedGuarded, no ErrorWidget.builder, and no crash reporting of any kind — all four independently re-derived by me from a different direction. Every error the authors did not predict produces a light grey rectangle on a kitchen tablet, writes nothing to the journal the pilot exists to produce, and is transmitted nowhere. For a product being sold to restaurants, that is disqualifying on its own, and HIGH is the right severity.
  • The two "destructive handler" pillars are weaker than presented. engine.dart:338 has no demonstrated production trigger — S5 concedes it, and I independently closed the one hole S5 left (the clone/parent def resolution) and still could not find one. journal.dart:200 is gated behind a 3 MB threshold I proved by test is not reached by anything smaller, on a file whose own author measured 361 lines per service. Both are real defects worth fixing; neither is HIGH.
  • S5-F7's mechanism is partly wrong, and it is one of the three changes S5 puts in its recommendation list. The surviving half (discarded booleans on speak/setVoice, with a journal line written before the call that no failure ever contradicts) is real, production-reachable and worth the fix. The sweeping half — that the native boundary can never deliver an error to Dart — is refuted by the Flutter embedding's own source.

So: the binary verdict is correct and I would not soften it. The accurate one-line statement of it is narrower than S5's — this codebase handles the errors it anticipated well and has no mechanism whatsoever for the ones it did not — and the ordered fix list should be S5-F2 first (it is the whole verdict), then S5-F5's else catch-all (two lines, and it is what makes S5-F2's new scopes visible), then R5-F1 and S5-F6, with S5-F1 and S5-F3 demoted to MEDIUM and de-duplicated against S1 and S2.

Stream S6: finding and refutation

S6 — DRY, dead code and bloatfindings/S6_dry_deadcode.md · raw .md

S6 — DRY, dead code and bloat

Stream: S6. Subject: the app repository at pinned commit 03a176e72ef0075eec86b8915cbe6e93042a3b9d (v0.4.12+18). Read-only on the pinned repo (R10); every build, mutation and encode experiment ran on the copy at a scratch working copy. All commands recorded through proof/run_and_record.sh [not published] into proof/01_findings/S6/.

Verdict

The codebase is NOT DRY today. Twenty-two distinct duplications are proven below, three of which are duplicated invariants that can silently diverge and change behaviour (the 5-second minimum duration, the 7000 ms first voice gap, the grid hit-test). Dead code is small but real: three dead public symbols and one dead font file. Dead assets in audio: zero — all 16 WAVs are referenced, including the backstop alarm that lives outside the Flutter asset bundle. Unused dependencies: zero of 15. The 53,629,091-byte APK is not caused by the app: 33,005,842 bytes (61.5 %) are two CPU architectures nobody's kitchen tablet will ever execute, measured, not asserted.


Findings

S6-F1 — The release APK ships three CPU architectures; 61.5 % of it is dead weight on every device

  • Severity: MEDIUM
  • Location: android/app/build.gradle.kts:30-36 (valid at 03a176e) — the buildTypes { release { … } } block sets only signingConfig and contains no splits/abiFilters; the build command in README.md:25 is flutter build apk --release, which produces a universal APK
  • What is wrong: flutter build apk --release with no --split-per-abi and no ABI filter emits a universal APK carrying libflutter.so + libapp.so + libdartjni.so + libdatastore_shared_counter.so for arm64-v8a, armeabi-v7a and x86_64. Native libraries are Stored (uncompressed) inside the zip, so every byte of all three is a byte of the download. A given tablet executes exactly one of the three. x86_64 in particular exists for emulators and Chromebooks; an Android kitchen tablet is arm64-v8a.
  • Evidence: 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.) - Why it matters for a restaurant kitchen: a restaurant tablet is usually on the venue's wifi behind a till system, and Play's mobile-data install warning trips at 200 MB but the update experience is what bites: every version bump re-downloads 53.6 MB instead of 20.6 MB. On a slow venue connection a mid-service update stalls, and the operator's board is the thing waiting. - Proposed fix: 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 the fix: 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-F2 — Engine.tickMs is dead while the heartbeat that it describes hard-codes the same number

  • Severity: MEDIUM
  • Location: lib/engine/engine.dart:32 (declaration, zero readers) and lib/ui/home.dart:154 (the literal that should read it)
  • What is wrong: the engine publishes static const int tickMs = 150 as the contract for its own heartbeat rate. Nothing reads it. The one place that needs it — the Timer.periodic that drives engine.tick() — writes 150 as a bare literal, and the comment two lines below writes 150ms a third time. Changing the tick rate therefore means editing a number in a file that does not own it, while the constant that claims to own it silently keeps the old value.
  • Evidence: 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:

    _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.
  • Why it matters for a restaurant kitchen: the alarm precision budget is built on the tick rate — Engine.alarmLeadMs = 1200 was chosen against a measured 902 ms voice lag, and the freeze detector at home.dart:160 compares against a 1500 ms threshold that only makes sense at 150 ms ticks. Someone tuning the heartbeat through the constant would change nothing and believe they had.
  • Proposed fix: 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 the fix: 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-F3 — Two dead colour constants in the palette

  • Severity: LOW
  • Location: lib/ui/theme.dart:23 (C.mint) and lib/ui/theme.dart:29 (C.logoInk)
  • What is wrong: both are declared and never read. C.mint looks alive because the word "mint" appears at theme.dart:58, but that is a doc comment on fillFor; the actual mint anchor used by the urgency ramp is C.fMint at line 37, a different value ([92, 199, 154] vs 0xFF0FA96A). C.logoInk was the header mark's ink before lib/ui/logo.dart became an Image.asset.
  • Evidence: 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)
  • Why it matters for a restaurant kitchen: it does not, directly. It matters because a palette with two colours nobody uses invites the next person to reach for C.mint believing it is the urgency green, and get 0xFF0FA96A where the pie actually paints [92, 199, 154] — two greens that differ on a tile the cook reads at arm's length.
  • Proposed fix: delete lib/ui/theme.dart:23 and lib/ui/theme.dart:29.
  • How to prove the fix: 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.

S6-F4 — The 5-second minimum duration is an invariant written at seven independent sites

  • Severity: MEDIUM
  • Location: lib/engine/engine.dart:368, lib/engine/engine.dart:372-374, lib/engine/models.dart:22, lib/ui/modals.dart:374, lib/ui/modals.dart:388, lib/ui/modals.dart:418, lib/ui/modals.dart:524
  • What is wrong: "a timer or a chain step is never shorter than 5 seconds" is a single product rule. It is encoded seven times as the bare literal 5, in three files, across the engine, the data model and the editor. The engine comment at engine.dart:363-365 explicitly acknowledges the duplication ("The modal already enforces these floors, but the engine holds its own invariants") without naming the number once. test/editor_layout_test.dart:76 asserts greaterThanOrEqualTo(5) with the comment "le plancher moteur est 5 s" — an eighth copy, in the test.
  • Evidence: proof/01_findings/S6/duplication_sites.txt
########## D3  the 5-second floor, six sites
lib/engine/engine.dart:368:        if (s.sec < 5) s.sec = 5;
lib/engine/engine.dart:372:    if (steps == null && (durationSec == null || durationSec < 5)) {
lib/engine/models.dart:22:      StepDef(name: (j['name'] ?? 'Step') as String, sec: (j['sec'] ?? 5) as int);
lib/ui/modals.dart:374:              sec: s.sec < 5 ? 5 : s.sec))
lib/ui/modals.dart:388:              durationSec: dur < 5 ? 5 : dur));
lib/ui/modals.dart:418:          if (min == 0 && sec == 0) sec = 5;
lib/ui/modals.dart:524:    s.sec = (m * 60 + ss) < 5 ? 5 : m * 60 + ss;

lib/engine/engine.dart:363-374 verbatim:

    // The modal already enforces these floors, but the engine holds its own
    // invariants whatever the caller (a 0s timer would ring instantly forever).
    if (name.trim().isEmpty) name = 'Timer';
    if (steps != null) {
      for (final s in steps) {
        if (s.sec < 5) s.sec = 5;
      }
      if (steps.length < 2) steps = null; // degenerate chain → single
    }
    if (steps == null && (durationSec == null || durationSec < 5)) {
      durationSec = 5;
    }
  • Why it matters for a restaurant kitchen: the floor exists because "a 0 s timer would ring instantly forever". Raise the floor in the editor and forget models.dart:22, and a corrupt or hand-edited stored step still loads at whatever the JSON says; lower it in the engine and forget modals.dart:524, and the editor silently rewrites what the cook typed. Both directions produce a board that disagrees with itself about a rule the kitchen can hear.
  • Proposed fix: add static const int minSecondsPerTimer = 5; to Engine (lib/engine/engine.dart, beside maxBatch at line 31) and a file-level const int kMinStepSec = 5; in lib/engine/models.dart that Engine.minSecondsPerTimer is defined as — models.dart has zero imports and must not import engine.dart, so models.dart owns the value and engine.dart re-exports it as static const int minSecondsPerTimer = kMinStepSec;. Call sites that change: engine.dart:368if (s.sec < minSecondsPerTimer) s.sec = minSecondsPerTimer;; engine.dart:372-374durationSec < minSecondsPerTimer / durationSec = minSecondsPerTimer; models.dart:22(j['sec'] ?? kMinStepSec) as int; modals.dart:374, :388, :418, :524Engine.minSecondsPerTimer (modals.dart does not import engine.dart today, so import ../engine/models.dart's kMinStepSec, which it already imports at line 7); test/editor_layout_test.dart:76greaterThanOrEqualTo(kMinStepSec).
  • How to prove the fix: on the copy, change the single constant to 9 and run flutter test. Today test/robustness_test.dart's saveDef floors group and test/editor_layout_test.dart:76 both keep passing because they carry their own 5; after the fix the editor-layout assertion goes red on the constant change, proving the value has exactly one owner.

S6-F5 — The grid hit-test is implemented twice in the same file, once inline and once as a helper

  • Severity: MEDIUM
  • Location: lib/ui/home.dart:509-530 (_panUpdate) and lib/ui/home.dart:609-615 (_tileIndexAt)
  • What is wrong: _tileIndexAt exists precisely to map a pointer position onto a tile index, and build uses it for onPanStart at home.dart:581-583. _panUpdate then recomputes the identical column/row/index arithmetic inline instead of calling it. The code map's 5-line window did not catch this because the two are written differently (localPos/gridOrigin vs pos/origin, early-return vs nested if), but the computation is the same expression three times over.
  • Evidence: proof/01_findings/S6/duplication_sites.txt, D1
  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) {
  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;
  }
  • Why it matters for a restaurant kitchen: drag-to-reorder is how the board is arranged to match the pass. The pick-up path and the drop-target path use two copies of the same geometry; a fix to one (say, clamping row against lay.rows) leaves the other picking up a tile it will then refuse to drop on. The cook sees a card lift and refuse to land, mid-service, with no way to tell why.
  • Proposed fix: delete lines 515-522 of _panUpdate and call the existing helper:
      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 the fix: 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

  • Severity: MEDIUM
  • Location: 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
  • What is wrong: EngineHost declares nine members. Six test files each implement all nine. Three of the copies (announcement_test, i18n_defaults_test, store_test) are byte-identical 20-line NullHost classes; backstop_test's FakeHost differs only in now(); robustness_test and engine_test add recording fields on top of the same nine overrides. Roughly 130 lines of test code exist to say "do nothing" six times.
  • Evidence: 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):
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) {}
}
  • Why it matters for a restaurant kitchen: EngineHost is the interface across which every alarm, every chime and every persistence write leaves the engine. Adding a tenth member — the natural way to add, say, an onBackstopDown signal — breaks six files at once, and the pressure under that is to make the new member optional rather than required. An optional host callback is a side effect the engine can forget to fire, which in this app is a silent alarm.
  • Proposed fix: 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 the fix: 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-F7 — The 7000 ms first voice gap is written three times, in two files that cannot see each other

  • Severity: MEDIUM
  • Location: lib/engine/engine.dart:52, lib/engine/models.dart:114, lib/engine/models.dart:139
  • What is wrong: Engine.firstVoiceGapMs = 7000 is the alarm-repeat escalation's starting gap. RunEntry's constructor defaults voiceGap to the bare literal 7000, and RunEntry.fromJson defaults it to the bare literal 7000 again. models.dart has zero imports by design (the code map records it as the only lib/ file with no import statement at all), so the duplication is structural rather than careless — but it is still three numbers that must agree and are not made to.
  • Evidence: 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,
  • Why it matters for a restaurant kitchen: the repeat gap is how insistently a finished dish keeps asking to be pulled — it shrinks by voiceGapFactor = 0.72 down to minVoiceGapMs = 2000. If the engine constant is retuned and the model defaults are not, a timer restored from disk after a kill starts its escalation from the old value while a freshly fired one starts from the new one. Two identical ringing pans on the same board then nag at different rates.
  • Proposed fix: 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 the fix: 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

  • Severity: LOW
  • Location: lib/ui/tile.dart:185-198
  • What is wrong: the running and paused arms of the status switch differ in exactly two things — where rem comes from and which colour fills the pie. Four of the six lines are identical, including the stepDur resolution that force-unwraps t.steps! and the clamp(0.0, 1.0).
  • Evidence: proof/01_findings/S6/duplication_sites.txt, D2
      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;
  • Why it matters for a restaurant kitchen: the pie wedge is the tile's whole message — the cook reads the shrinking colour, not the digits, from three metres away. A divide-by-zero guard or a chained-step fix applied to the running arm and not the paused arm gives a paused card a wedge that disagrees with its own countdown.
  • Proposed fix: 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 the fix: 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.

S6-F9 — RunStatus is re-encoded as bare strings throughout tile.dart

  • Severity: MEDIUM
  • Location: lib/ui/tile.dart:101-105 (_status), then eleven comparison sites at :110, :111, :182, :185, :192, :205, :207, :208, :257, :569
  • What is wrong: lib/engine/models.dart:91 declares enum RunStatus { running, paused, ringing }. tile.dart converts it back to a String via r.status.name, invents a fourth value 'idle' for the null case, and then compares against string literals eleven times. The compiler cannot check a single one of them: a typo ('runing') analyses clean and silently makes a running tile render as the default arm, which is the ringing arm.
  • Evidence: 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:

  String get _status {
    final r = widget.r;
    if (r == null) return 'idle';
    return r.status.name;
  }
      default: // ringing
        timeText = fmtUp((widget.nowMs - (r!.rangAt ?? widget.nowMs)) / 1000.0);
  • Why it matters for a restaurant kitchen: the default arm force-unwraps r!. Any status string that is neither idle, running nor paused falls into it. Today RunStatus has exactly three values so the fall-through is always ringing and r is always non-null — but the type system is not enforcing that, the string equality is. Add a fourth RunStatus value and the tile crashes on r! for a stopped timer, taking the whole board down mid-service.
  • Proposed fix: 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 the fix: 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.

S6-F10 — Four styling duplications inside modals.dart

  • Severity: LOW
  • Location: lib/ui/modals.dart:54-62 vs :719-727; :253-258 vs :338-343; :482-492 vs :535-545; :302-304
  • What is wrong: four blocks are written twice each in a single 746-line file. (a) _fieldLabel (54-62) and _SettingsState._settingLabel (719-727) are the same widget — bottom-padded uppercase mono label — differing only in four numbers. (b) The timer-name TextField style (253-258) and the announcement TextField style (338-343) are byte-identical six-line const TextStyle blocks. (c) The step-name field decoration (482-492) and the _numBox decoration (535-545) are the same _inputDeco().copyWith(counterText: '', contentPadding: …, fillColor: C.panel) plus the same mono style, differing only in horizontal padding. (d) _dashedAdd (548-567) is named for a dashed border and draws BorderStyle.solid (line 556) — a name that lies about what it does, which is the documentation equivalent of the same defect.
  • Evidence: proof/01_findings/S6/duplication_sites.txt, D8/D9/D10. (b) verbatim, both copies:
        style: const TextStyle(
            fontFamily: F.display,
            fontWeight: FontWeight.w700,
            fontSize: 20.8,
            letterSpacing: 1,
            color: C.text),

(a) verbatim, both copies:

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)),
    );
  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)),
      );
  • Why it matters for a restaurant kitchen: the editor is the one screen a chef touches while the board is live. Two field labels that are meant to look the same and are maintained separately drift the moment one is adjusted for a narrow tablet; the operator reads two type scales in one dialog and loses the sense that the fields belong to one form.
  • Proposed fix: in lib/ui/modals.dart, (a) give the existing top-level _fieldLabel optional named parameters {double size = 10.9, double tracking = 2.2, Color color = C.muted, double gap = 8} and delete _settingLabel (719-727), replacing its three call sites at :622, :634, :677 with _fieldLabel(tr(...), size: 14.7, tracking: 2.3, color: C.text, gap: 12); (b) hoist the shared style to a file-private const TextStyle _nameFieldStyle = TextStyle(fontFamily: F.display, fontWeight: FontWeight.w700, fontSize: 20.8, letterSpacing: 1, color: C.text); and use it at :253 and :338; (c) add InputDecoration _compactDeco({String? hint, required double hPad}) => _inputDeco(hint: hint).copyWith(counterText: '', contentPadding: EdgeInsets.symmetric(horizontal: hPad, vertical: 9), fillColor: C.panel); plus const TextStyle _cellStyle = TextStyle(fontFamily: F.mono, fontWeight: FontWeight.w700, fontSize: 16, color: C.text); and use both at :482 (hPad 8) and :535 (hPad 4); (d) rename _dashedAdd to _addStepBtn.
  • How to prove the fix: 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

  • Severity: LOW
  • Location: lib/ui/tile.dart:441-446 vs :696-701 (shadow); lib/ui/tile.dart:438-439 vs :598 (radius formula); lib/ui/tile.dart:218 vs :798 (the literal 20)
  • What is wrong: the ×N batch chip and the ±10 s / ✕ control buttons are deliberately "the same visual language" (the comment at :424-426 says so) and each carries its own copy of the identical BoxShadow(color: Color(0x291C211C), blurRadius: 4, offset: Offset(0, 1)). The rounded-corner formula math.min(10, 2.6 * ch) appears at :439 and again as math.min(10.0, 2.6 * ch) at :598 — the same expression with a different literal type. Separately the tile's own corner radius 20 is written at :218 (BorderRadius.circular(20)) and re-derived at :798 (Radius.circular(20 - inset)) inside the dashed-outline painter, which must stay in step with it or the edit-mode outline stops tracking the card's corners.
  • Evidence: proof/01_findings/S6/duplication_sites.txt, D11/D12
                        boxShadow: const [
                          BoxShadow(
                              color: Color(0x291C211C),
                              blurRadius: 4,
                              offset: Offset(0, 1))
                        ],
            boxShadow: const [
              BoxShadow(
                  color: Color(0x291C211C),
                  blurRadius: 4,
                  offset: Offset(0, 1))
            ],
                        borderRadius:
                            BorderRadius.circular(math.min(10, 2.6 * ch)),
    final radius = math.min(10.0, 2.6 * ch);
  • Why it matters for a restaurant kitchen: the relief on those buttons is what tells a cook they are touchable — the comment at :448-452 records that the chip was redesigned precisely because cooks misread it. Two copies of the affordance drift the first time one is tuned, and half the controls stop reading as buttons.
  • Proposed fix: 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 the fix: 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

  • Severity: LOW
  • 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
  • What is wrong: every widget test that needs a dialog builds the identical MaterialApp → Builder → TextButton(child: Text('open')) scaffold, taps it and pumps. Four copies. Two of the files additionally set physicalSize / devicePixelRatio and register the identical five-line addTearDown reset.
  • Evidence: proof/01_findings/S6/duplication_sites.txt, D5/D6
########## D5  modal-opening widget-test harness, four sites
test/announcement_test.dart:214:            child: const Text('open'),
test/editor_layout_test.dart:28:          child: const Text('open'),
test/volume_test.dart:149:            child: const Text('open'),
test/volume_test.dart:179:            child: const Text('open'),

########## D6  viewport override + teardown, two sites
test/announcement_test.dart:192:      v.physicalSize = const Size(1600, 1400);
test/editor_layout_test.dart:42:      v.physicalSize = const Size(600, 1400);

test/editor_layout_test.dart:41-47 verbatim (test/announcement_test.dart:191-197 is the same five lines with a different Size):

      final v = TestWidgetsFlutterBinding.instance.platformDispatcher.views.first;
      v.physicalSize = const Size(600, 1400);
      v.devicePixelRatio = 1.0;
      addTearDown(() {
        v.resetPhysicalSize();
        v.resetDevicePixelRatio();
      });
  • Why it matters for a restaurant kitchen: the editor and settings dialogs are the only screens with any widget coverage at all. Four private harnesses is the reason nobody adds a fifth widget test — and lib/ui/home.dart, lib/ui/tile.dart and lib/ui/header.dart sit at 0.00 % as a result. Untested tile rendering is untested alarm signalling.
  • Proposed fix: create test/support/modal_harness.dart with Future<void> openModal(WidgetTester tester, void Function(BuildContext c) open) async (pumps the MaterialApp/Builder/TextButton scaffold, taps find.text('open'), pumpAndSettle) and void useViewport(Size size) (sets physicalSize/devicePixelRatio and registers the addTearDown reset). Call sites that change: announcement_test.dart:189-197useViewport(const Size(1600, 1400));; announcement_test.dart:200-222openModal + capture of the result; editor_layout_test.dart:22-34 and :41-47; volume_test.dart:137-158 and :169-183.
  • How to prove the fix: flutter test stays at 123 passed with the six blocks deleted, and git grep -c "child: const Text('open')" test/ returns 0 (4 today).

S6-F13 — The duration-preset label is duplicated across the production/test boundary

  • Severity: LOW
  • Location: lib/ui/modals.dart:302-304 and test/editor_layout_test.dart:17-20
  • What is wrong: the test that guards the preset row builds the chip label itself, with a comment admitting the coupling ("kept in sync with the chip builder"). A test that reimplements the production expression cannot detect a change in it — it can only detect that the widget tree moved. Rename the format and both change together, and the test still passes.
  • Evidence: proof/01_findings/S6/duplication_sites.txt, D7
              child: Text(p[1] != 0
                  ? '${p[0]}:${p[1].toString().padLeft(2, '0')}'
                  : '${p[0]}'),
  /// 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]}';
  • Why it matters for a restaurant kitchen: the six presets are the fastest way to set a timer during a rush. The one test protecting them from wrapping onto a second line is blind to the label itself; a formatting change that turns 0:30 into 30s and widens the row past the tablet would ship green.
  • Proposed fix: 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 the fix: 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

  • Severity: LOW
  • Location: pubspec.yaml:61-62, file assets/fonts/ChivoMono-Medium.ttf
  • What is wrong: pubspec.yaml declares the Chivo Mono family at weights 400, 500 and 700. Every F.mono site in lib/ either sets fontWeight: FontWeight.w700 explicitly or sets no weight at all, which resolves to the ambient w400. FontWeight.w500 and FontWeight.w600 are never requested anywhere in lib/ — the only non-700/800 weight in the whole codebase is a single w600 at lib/ui/home.dart:699, which carries no fontFamily and therefore falls to the app default family F.display, not to mono. Weights 400 and 700 are both declared exactly, so no request ever falls through to 500. The file is 59,404 bytes on disk and 29,863 bytes compressed inside the APK.
  • Evidence: 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:

            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}. - Why it matters for a restaurant kitchen: it does not affect service. It matters because a font declared and never selected is a licence obligation with no product behind it — the baseline records 0 tracked licence files for 7 TTF fonts, and the store-compliance stream has to reconcile seven font licences where six are actually rendered. - Proposed fix: 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 the fix: 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-F15 — Three of the sixteen WAVs have no generator; the documented source of truth cannot reproduce them

  • Severity: MEDIUM
  • Location: tools/build_ringtones.py:1-238; the three unreproducible files are assets/audio/step.wav, assets/audio/click-up.wav, assets/audio/click-down.wav
  • What is wrong: the generator's own header calls itself "source of truth for the alarm sounds" and is dated 2026-07-27. It writes 13 files: the 12 picker tones plus android/app/src/main/res/raw/cadence_alarm.wav. The step chime and the two click sounds — played by lib/audio/audio.dart:89 and :92 — have no recipe anywhere in the repository. They exist only as bytes on disk. Re-running the generator today reproduces all 13 of its files bit for bit, so the tool is current for what it owns; the gap is coverage, not drift.
  • Evidence: 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.) - Why it matters for a restaurant kitchen: step.wav is the chime that marks a chain phase boundary — the signal to flip the chicken. It is loudness-matched to nothing, documented nowhere, and unrecoverable if the file is corrupted or a future asset pass re-levels the other twelve. A step chime that no longer matches the tones it sits between is a signal the cook stops trusting. - Proposed fix: 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 the fix: 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 — The backstop alarm sound is invisible to the shrinker and pinned by a single keep.xml

  • Severity: MEDIUM
  • Location: 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
  • What is wrong: the 16th WAV is not a Flutter asset. It is an Android resource, delivered by aapt rather than by the Flutter asset bundle, and it is not listed in pubspec.yaml's assets: block. Its only reference is the Dart string 'cadence_alarm' inside RawResourceAndroidNotificationSound, which no Android tool can see. keep.xml exists solely to stop the release resource shrinker deleting it, and its comment records that this already happened once: "audit: v0.3 first build shipped without it". Today android/app/build.gradle.kts sets neither isMinifyEnabled nor isShrinkResources, so keep.xml is currently belt-and-braces — which is exactly why it reads as deletable dead config to anyone tidying the tree, and exactly why it must not be deleted.
  • Evidence: 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"?>
<!-- The backstop alarm sound is referenced only from Dart (invisible to the
     release resource shrinker, which would strip it — audit: v0.3 first build
     shipped without it). tools:keep pins it into the APK. -->
<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)
  • Why it matters for a restaurant kitchen: this is the sound that fires when the app process is dead. Every other alarm in this product depends on Dart being alive; this one does not. Losing it is the failure mode the whole backstop exists to prevent — the tablet posts a full-screen notification with no sound, in a kitchen, over extraction hoods. It shipped that way once already.
  • Proposed fix: 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.
  • How to prove the fix: 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.

S6-F17 — The alarm-acknowledgement journal line is written twice in home.dart

  • Severity: LOW
  • Location: lib/ui/home.dart:366-371 and lib/ui/home.dart:656-661
  • What is wrong: stopping a ringing timer by tapping the tile and stopping it by pressing the ✕ produce the same journal record — label, elapsed-since-rangAt, one decimal — computed twice, with two different clock sources (now() vs the nowMs captured at build) and two different null messages ('alarme coupee' vs 'via la croix').
  • Evidence: proof/01_findings/S6/duplication_sites.txt, D15
      // 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');
      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');
  • Why it matters for a restaurant kitchen: time-to-acknowledge is one of the two numbers the flight recorder exists to produce (lib/journal.dart:13-14 names it). The ✕ path measures against nowMs, which is the timestamp of the last build, up to 150 ms stale; the tap path measures against now(). The pilot's headline metric is computed two ways depending on which control the cook happened to press.
  • Proposed fix: 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 the fix: 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 — Four independent copies of a two-digit zero-pad, and two of the alarm notification title

  • Severity: LOW
  • Location: 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. Notification title: lib/alarm_backstop.dart:186 and :259
  • What is wrong: n.toString().padLeft(2, '0') appears ten times across five files, and one of those files (journal.dart) already declares a local helper for it at line 57 that it then does not use at lines 62, 222 or 223. modals.dart:704 declares a second local p(int n) with the same body inside _sendJournal. Separately, the backstop's notification title format '⏰ $name' is written twice, once with engine.labelFor (which appends [lot 2]) and once with the raw t.name (which does not) — so a scheduled backstop names the batch and an immediate one does not.
  • Evidence: 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,
  • Why it matters for a restaurant kitchen: the title asymmetry is the one that bites. When the app is killed, the OS notification says "⏰ Fries [lot 2]" and the cook knows which pan; when the app is merely backgrounded, showNow posts "⏰ Fries" and three identical notifications stack with no way to tell which batch finished.
  • Proposed fix: (a) add String pad2(int n) => n.toString().padLeft(2, '0'); as a top-level function in lib/ui/theme.dart (already imported by every UI file) and a private static String _pad2(int n) in Journal; change journal.dart:62, :222, :223 to use the existing local helper by lifting it to a static method and deleting the inline p at :57's duplicate use; header.dart:145, :147, modals.dart:399, :502, theme.dart:79 to pad2(...); delete modals.dart:704 and use pad2. (b) In lib/alarm_backstop.dart, add String _title(String name) => '⏰ $name'; and change :186 to title: _title(name) and :259 to title: _title(t.name) — then, separately, showNow should be passed the label rather than the raw name so the two paths agree; that is a one-word change at lib/ui/home.dart:300 (backstop.showNow(t, …) already has the engine in scope).
  • How to prove the fix: 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 — README.md documents the v0.1 architecture and contradicts the shipped app

  • Severity: LOW
  • Location: README.md:1-38 (dated "Last update: 2026-07-22" at README.md:3; the pinned commit is v0.4.12)
  • What is wrong: the README states as a known gap that background alarms are "pas encore branchées" and lists flutter_local_notifications as merely "prévu", while lib/alarm_backstop.dart is 279 lines of shipped background-alarm code with its own test file. It says at README.md:18 that test/engine_test.dart holds "17 tests unitaires"; the code map counts 21. At README.md:12 it describes models.dart as carrying a "zone" concept, and at README.md:16 it lists a "modales (éditeur, zones, réglages)" screen — lib/engine/models.dart:6-13 records zones as removed in v0.4.11 and there is no zones modal in lib/ui/modals.dart. It says TTS voice selection is "défaut OS (pas de scoring de voix)"; lib/audio/voice.dart:104-142 is a 39-line voice scorer. It documents the build command at README.md:25 as flutter build apk --release, which is what produces the 53.6 MB universal APK of S6-F1.
  • Evidence: README.md:18, README.md:25 and README.md:32-34 verbatim:
- `test/engine_test.dart` — 17 tests unitaires (protocole spec §6.1)
flutter build apk --release   # → build/app/outputs/flutter-apk/app-release.apk
- **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.
- **iOS** : plateforme générée mais jamais buildée (Mac de the project owner) ; volume système iOS = MPVolumeView à intégrer côté Mac.
- Sélection de voix TTS = défaut OS (pas de scoring de voix comme le proto web).

Against lib/audio/voice.dart:91-103, which documents the scorer that the README says does not exist, and test/backstop_test.dart:1-3, which names the background-alarm regressions it guards. - Why it matters for a restaurant kitchen: the README is what a second engineer reads before touching the alarm path. Told that background alarms are unimplemented, they would build them — on top of a working backstop, producing two systems racing to notify the same kitchen. - Proposed fix: 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 the fix: 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-F20 — analysis_options.yaml carries an empty linter: rules: block

  • Severity: LOW
  • Location: analysis_options.yaml:12-25
  • What is wrong: the file declares a linter: section with a rules: key whose only contents are two commented-out examples from the Flutter template. It configures nothing. It is the stock file, unmodified, in a project whose baseline is 0 analyzer issues with --fatal-infos --fatal-warnings — meaning the team is already holding a bar stricter than the file describes, with nothing recording that fact.
  • Evidence: analysis_options.yaml:12-25 verbatim:
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
  • Why it matters for a restaurant kitchen: indirectly. The analyzer is the only thing in this project that proves there is no dead private code (unused_element / unused_field are on by default and unsuppressed — see the negative result below). Leaving the configuration as template boilerplate means nobody has decided which rules the alarm path is held to.
  • Proposed fix: 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 the fix: 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 — The web/ target is the unmodified stock Flutter template and has never been touched

  • Severity: LOW
  • Location: web/index.html:1-46, web/manifest.json:1-35, web/favicon.png, web/icons/* (4 files)
  • What is wrong: Cadence is an Android/iOS app. The web/ directory was created by flutter create in the very first commit and has not been modified since. It still advertises the project as "A new Flutter project", titles the page cadence in lowercase, and carries the default Flutter blue #0175C2 theme colour. No Dart file is web-aware, no build script targets web, and there is no CI.
  • Evidence: 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)
  • Why it matters for a restaurant kitchen: it does not affect the tablet. It affects the store submission: a directory advertising the product as "A new Flutter project" is one careless flutter build web away from being published under Serge's name, and it is dead surface that a reviewer of this repository has to read past.
  • Proposed fix: 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.
  • How to prove the fix: 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.

S6-F22 — uses-material-design: true ships an icon font for zero icons

  • Severity: LOW
  • Location: pubspec.yaml:42
  • What is wrong: the app uses no Material icon anywhere — every glyph in the UI is a literal character ('⚙', '✕', '▲', '▼', '🗑', '◷', '✎ EDIT'). The declaration still pulls in MaterialIcons-Regular.otf, which the tree-shaker reduces from 1,645,184 bytes to 1,256 bytes and ships as 697 compressed bytes.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: it does not. It is listed because it is a provable, subtractive, one-line deletion, and because the 99.9 % tree-shake is itself the proof that nothing in the app asks for an icon.
  • Proposed fix: set pubspec.yaml:42 to uses-material-design: false.
  • How to prove the fix: 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-F23 — REPORTED, not proposed: the 16 WAVs ship uncompressed

  • Severity: LOW
  • Location: assets/audio/*.wav (15 files), android/app/src/main/res/raw/cadence_alarm.wav
  • What is wrong: nothing is wrong. WAV is uncompressed PCM and the Android packager stores it without deflating it (Stored, 0 % in the zip listing), so all 1,941,694 bytes of audio are 1,941,694 bytes of download. Encoding to MP3 at 96 kbps would cost 276,880 bytes for the same 16 files. Audio quality on a kitchen speaker is a product decision, not a refactor (R6) — this is measured and reported so the number exists, not proposed.
  • Evidence: 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. - Why it matters for a restaurant kitchen: the generator's own header (tools/build_ringtones.py:34-40) records rule 2: "Peak-normalised, NOT RMS-matched. These were validated by ear on the kitchen tablet in that exact form — re-levelling them afterwards would ship something nobody signed off on." A lossy encode is a re-levelling of a different kind. The tones were signed off as bytes. - Proposed fix: none. Serge decides. If he wants the 1.6 MB, the change is mechanical (ffmpeg -c:a libmp3lame -b:a 96k per file; audioplayers plays MP3 on both platforms and RawResourceAndroidNotificationSound accepts a .mp3 resource) and must be re-validated by ear on the kitchen tablet before it ships. - How to prove the fix: not applicable — no fix is proposed. If it is ever taken up, the gate is a listening test on the pilot tablet, not a byte count.


Negative results — scoped areas where I found nothing

Area checked Result Proof
Unused direct dependencies (10) Zero. Every one is imported and used at a named symbol. proof/01_findings/S6/deps_usage.txt
Unused dev dependencies (5) Zero. Three are imported in Dart; flutter_lints is consumed by analysis_options.yaml:10; flutter_launcher_icons by pubspec.yaml:33-39. proof/01_findings/S6/deps_usage.txt
Orphan WAVs in assets/audio/ Zero. 15 referenced, 15 on disk, sets identical. proof/01_findings/S6/assets_wav_orphan_check.txt
Referenced-but-missing WAVs Zero. No C.tones entry maps to a file that is not on disk. same
The 16th WAV (res/raw/cadence_alarm.wav) Referenced and shipped, byte-identical inside the APK as res/pC.wav. Not an orphan. Do not delete. proof/01_findings/S6/backstop_alarm_16th_wav.txt
Dead private code (unused private methods, fields, classes) Zero. unused_element and unused_field are on by default and unsuppressed; the baseline records flutter analyze --fatal-infos --fatal-warnings exiting 0. proof/01_findings/S6/dead_symbols_grep.txt §5; baseline SUMMARY
Unused constructor parameters / widget fields Zero. All 19 TileView fields, all 6 Header fields, all 10 _CtlBtn fields and both _DashedOutline fields have at least one read inside their own file. verified by reading lib/ui/tile.dart, lib/ui/header.dart end to end
Dead enum values Zero. All three RunStatus values are constructed and matched. git grep RunStatus.
Unused imports Zero. unused_import is a warning; analyze exits 0 under --fatal-warnings. baseline SUMMARY
assets/icon/*.png (3 files) Not orphans. Consumed by flutter_launcher_icons config at pubspec.yaml:36-39; deliberately not in the bundled assets: list, and correctly absent from the APK's flutter_assets. proof/01_findings/S6/apk_composition.txt
Test-only public API One: VoiceBox.pending (lib/audio/voice.dart:43) has no production caller; read only at test/voice_test.dart:163 and :178. Its doc comment declares the intent ("tests; also handy from a debug screen"). Not dead — flagged, not proposed for deletion. git grep -n '\.pending' lib test

Duplication table

# What is duplicated Locations (valid at 03a176e) Proposed centralised utility
D1 Grid hit-test (pointer → tile index) lib/ui/home.dart:515-522; lib/ui/home.dart:609-615 Call the existing _HomeScreenState._tileIndexAt; better, move it to GridLayout as int? indexAt(Offset pos, Offset origin, int n) in lib/ui/grid_layout.dart
D2 Running/paused remaining-time + pie fraction lib/ui/tile.dart:186-190; lib/ui/tile.dart:193-197 double pieFraction(int stepDurSec, double remainingMs) in lib/ui/theme.dart, plus a private _remaining in _TileViewState
D3 The 5-second minimum duration lib/engine/engine.dart:368, :372, :373; lib/engine/models.dart:22; lib/ui/modals.dart:374, :388, :418, :524; test/editor_layout_test.dart:76 const int kMinStepSec = 5; in lib/engine/models.dart, re-exported as Engine.minSecondsPerTimer
D4 The nine-member EngineHost no-op body 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 test/support/engine_hosts.dartNullHost, ClockHost extends NullHost, RecordingHost extends ClockHost
D5 Modal-opening widget-test harness test/announcement_test.dart:200-222; test/editor_layout_test.dart:22-34; test/volume_test.dart:137-158; test/volume_test.dart:169-183 Future<void> openModal(WidgetTester, void Function(BuildContext)) in test/support/modal_harness.dart
D6 Viewport override + addTearDown reset test/announcement_test.dart:191-197; test/editor_layout_test.dart:41-47 void useViewport(Size size) in test/support/modal_harness.dart
D7 Duration-preset chip label lib/ui/modals.dart:302-304; test/editor_layout_test.dart:18-20 String presetLabel(List<int> p) in lib/ui/theme.dart beside C.presets
D8 Field-label widget lib/ui/modals.dart:54-62; lib/ui/modals.dart:719-727 Parameterise the existing _fieldLabel with {size, tracking, color, gap}; delete _settingLabel
D9 Name/announcement TextField text style lib/ui/modals.dart:253-258; lib/ui/modals.dart:338-343 const TextStyle _nameFieldStyle in lib/ui/modals.dart
D10 Compact input decoration + mono cell style lib/ui/modals.dart:482-492; lib/ui/modals.dart:535-545 InputDecoration _compactDeco({String? hint, required double hPad}) and const TextStyle _cellStyle in lib/ui/modals.dart
D11 Button drop shadow lib/ui/tile.dart:441-446; lib/ui/tile.dart:696-701 static const List<BoxShadow> btnShadow on C in lib/ui/theme.dart
D12 Control-button corner-radius formula lib/ui/tile.dart:439; lib/ui/tile.dart:598 double ctlRadius(double ch) in lib/ui/tile.dart
D13 Tile corner radius 20 lib/ui/tile.dart:218; lib/ui/tile.dart:798 static const double tileRadius = 20.0; on C
D14 Tick period 150 (constant vs literal) lib/engine/engine.dart:32 (dead); lib/ui/home.dart:154, :157 (comment) Engine.tickMs — read it
D15 First voice gap 7000 lib/engine/engine.dart:52; lib/engine/models.dart:114; lib/engine/models.dart:139 const int kDefaultVoiceGapMs = 7000; in lib/engine/models.dart
D16 Alarm-acknowledgement journal line lib/ui/home.dart:366-371; lib/ui/home.dart:656-661 void _logStop(String id, String tileId, {required String nullMsg}) in _HomeScreenState
D17 Backstop notification title '⏰ …' lib/alarm_backstop.dart:186; lib/alarm_backstop.dart:259 String _title(String name) in Backstop (and pass the label, not t.name, from lib/ui/home.dart:300)
D18 Two-digit zero pad lib/journal.dart:57, :62, :222, :223; lib/ui/header.dart:145, :147; lib/ui/modals.dart:399, :502, :704; lib/ui/theme.dart:79 String pad2(int n) in lib/ui/theme.dart; static String _pad2(int) in Journal (which must not import UI)
D19 RunStatus re-encoded as string literals lib/ui/tile.dart:103, :110, :111, :182, :185, :192, :205, :207, :208, :257, :569 The RunStatus enum itself (lib/engine/models.dart:91) — _status returns RunStatus?
D20 Default names 'Timer' / 'Step' lib/engine/engine.dart:365; lib/engine/models.dart:22, :67; lib/ui/modals.dart:312, :364, :373 const String kDefaultTimerName = 'Timer'; const String kDefaultStepName = 'Step'; in lib/engine/models.dart beside kDefaultSound
D21 Shared test import block test/robustness_test.dart:4-10; test/store_test.dart:4-10 Absorbed by D4's test/support/engine_hosts.dart, which can re-export the model/engine/store imports
D22 Store-fixture boilerplate (setMockInitialValuesStore.openEngine(NullHost())load) test/store_test.dart:79-83, :127-131, :146-150, :161-165, :212-216; test/robustness_test.dart:49-53, :69-73 Future<(Store, Engine)> freshStore(Map<String, Object> prefs) in test/support/engine_hosts.dart

Dead-code table

Every entry carries the grep that proves zero call sites. All greps run from the repo root at 03a176e; full output in proof/01_findings/S6/dead_symbols_grep.txt.

Symbol Declared at Grep proof of zero callers Verdict
C.logoInk lib/ui/theme.dart:29 git grep -n "logoInk"one line, the declaration itself: lib/ui/theme.dart:29: static const logoInk = Color(0xFFF5F1E8); DEAD — delete
C.mint lib/ui/theme.dart:23 git grep -n "\bmint\b" → two lines, theme.dart:23 (declaration) and theme.dart:58 (a /// doc comment). Comment-stripped: only the declaration survives. DEAD — delete
Engine.tickMs lib/engine/engine.dart:32 git grep -n "tickMs"one line, the declaration itself: lib/engine/engine.dart:32: static const int tickMs = 150; DEAD as written — the correct repair is to make lib/ui/home.dart:154 read it (S6-F2), not to delete it
VoiceBox.pending lib/audio/voice.dart:43 git grep -n "\.pending" lib testtest/voice_test.dart:163, test/voice_test.dart:178. Zero lib/ callers. NOT dead — test-only public API, flagged per brief, keep
Journal.beatQuietMs lib/journal.dart:49 git grep -n "beatQuietMs"journal.dart:49 (declaration) and journal.dart:145 (read). NOT dead — public but only self-read; keep
Any private member flutter analyze --fatal-infos --fatal-warnings exits 0 (baseline), and analysis_options.yaml suppresses no unused_* rule. unused_element and unused_field are on. Zero dead private code, proven by the analyzer

Confirmed dead symbols: 3 (C.logoInk, C.mint, Engine.tickMs).


Asset-usage table — 16 WAVs

15 in the Flutter asset bundle (assets/audio/, declared at pubspec.yaml:45) plus one Android resource (android/app/src/main/res/raw/, not in pubspec.yaml, delivered by aapt). Reconciliation proof: proof/01_findings/S6/assets_wav_reconciliation.txt, assets_wav_orphan_check.txt, backstop_alarm_16th_wav.txt.

# File Bytes Delivery Referenced from Used? Regenerable by tools/build_ringtones.py?
1 assets/audio/chirp.wav 75,182 Flutter bundle C.tones[0] (theme.dart:46) → SoundBox.assetFor (audio.dart:83) YES yes (:197)
2 assets/audio/coin.wav 206,870 Flutter bundle C.tones[1]assetFor YES yes (:202)
3 assets/audio/fanfare.wav 192,316 Flutter bundle C.tones[2]assetFor YES yes (:209)
4 assets/audio/pop.wav 124,400 Flutter bundle C.tones[3]assetFor YES yes (:215)
5 assets/audio/cascade.wav 366,956 Flutter bundle C.tones[4]assetFor YES yes (:223)
6 assets/audio/bowl.wav 308,744 Flutter bundle C.tones[5]assetFor YES yes (:228)
7 assets/audio/bell.wav 97,064 Flutter bundle C.tones[6]assetFor; also Store._legacyFallbackSound = 'Bell' (store.dart:238) YES yes (:106)
8 assets/audio/beep.wav 30,914 Flutter bundle C.tones[7]assetFor YES yes (:94)
9 assets/audio/chime.wav 82,952 Flutter bundle C.tones[8]assetFor YES yes (:110)
10 assets/audio/ping.wav 52,964 Flutter bundle C.tones[9]assetFor YES yes (:97)
11 assets/audio/buzz.wav 61,784 Flutter bundle C.tones[10] = 'Buzz'assetFor YES yes (:126)
12 assets/audio/marimba.wav 87,356 Flutter bundle C.tones[11]assetFor YES yes (:118)
13 assets/audio/step.wav 54,728 Flutter bundle SoundBox.stepChime() lib/audio/audio.dart:89 YES NO — S6-F15
14 assets/audio/click-up.wav 28,268 Flutter bundle SoundBox.click(true) lib/audio/audio.dart:92 YES NO — S6-F15
15 assets/audio/click-down.wav 28,268 Flutter bundle SoundBox.click(false) lib/audio/audio.dart:92 YES NO — S6-F15
16 android/app/src/main/res/raw/cadence_alarm.wav 142,928 Android resource (res/pC.wav in the APK, md5 8cdc98e2f7b9e467cb5b3c99de8fcf50, byte-identical) RawResourceAndroidNotificationSound('cadence_alarm') lib/alarm_backstop.dart:55; pinned by res/raw/keep.xml:6 YES — fires when the app process is dead. NEVER DELETE (S6-F16) yes (:236)

Orphans: 0. Referenced-but-missing: 0. Both directions verified by set comparison, not by eye — comm -23 and comm -13 over the disk listing and the derived reference list both returned empty, and diff reported the sets identical.

Coverage gap worth naming: test/i18n_defaults_test.dart:73-77 asserts existence and lengthSync() > 1000 for the 12 picker tones only. Rows 13, 14, 15 and 16 — the step chime, both clicks and the backstop alarm — have no test asserting they exist.

Asset-usage table — 7 fonts

Declared at pubspec.yaml:48-68. Requested weights derived from every fontFamily/fontWeight pair in lib/; proof at proof/01_findings/S6/font_weight_usage.txt.

File Family Declared weight Bytes (disk / compressed in APK) Requested by Used?
BigShouldersDisplay-Medium.ttf Big Shoulders Display 500 68,592 / 35,009 app-default weight w400 from ThemeData(fontFamily: F.display) (main.dart:52) on unstyled Text at modals.dart:453, modals.dart:510, home.dart:708; resolves to the nearest declared weight, 500 YES
BigShouldersDisplay-Bold.ttf Big Shoulders Display 700 68,456 / 34,967 w700 at header.dart:82, header.dart:205, home.dart:714, modals.dart:107, modals.dart:255, modals.dart:340 YES
BigShouldersDisplay-ExtraBold.ttf Big Shoulders Display 800 68,628 / 34,831 w800 at header.dart:70, modals.dart:47, modals.dart:138, tile.dart:408, tile.dart:734 YES
ChivoMono-Regular.ttf Chivo Mono 400 59,412 / 30,234 F.mono with no weight (→ w400) at modals.dart:58, :408, :434, :501, :668, :685, :723 YES
ChivoMono-Medium.ttf Chivo Mono 500 59,404 / 29,863 nothing. FontWeight.w500 appears nowhere in lib/; the single w600 (home.dart:699) carries no fontFamily and falls to the display family. Weights 400 and 700 are declared exactly, so no mono request falls through to 500. NO — S6-F14
ChivoMono-Bold.ttf Chivo Mono 700 59,356 / 30,320 w700 at modals.dart:107 (pill branch), :402, :473, :489, :542, :562, :640; tile.dart:457, :532, :555, :708 YES
DSEG7Classic-Bold.ttf DSEG7 Classic 700 23,040 / 6,855 w700 at header.dart:136, tile.dart:477, tile.dart:745 YES

Unused fonts: 1 of 7 (ChivoMono-Medium.ttf, 29,863 compressed bytes in the APK).


Dependency-usage table

All 15 declared dependencies. Proof: proof/01_findings/S6/deps_usage.txt.

Package Version constraint Import site Symbol actually used Verdict
flutter sdk everywhere USED
shared_preferences ^2.3.0 lib/engine/store.dart:7, lib/journal.dart:20 SharedPreferences.getInstance() store.dart:30 USED
audioplayers ^6.1.0 lib/audio/audio.dart:5 AudioPlayer() audio.dart:38, AssetSource :73, AudioContext :23 USED
vibration ^3.1.3 lib/audio/audio.dart:7 Vibration.hasVibrator() :50, Vibration.vibrate :99, :107 USED
wakelock_plus ^1.2.8 lib/main.dart:5 WakelockPlus.enable() main.dart:28 USED
flutter_local_notifications ^22.1.0 lib/alarm_backstop.dart:19 FlutterLocalNotificationsPlugin() :28, zonedSchedule :184, show :258 USED
timezone ^0.11.1 lib/alarm_backstop.dart:20-21 tzdata.initializeTimeZones() :71, tz.TZDateTime.fromMillisecondsSinceEpoch(tz.UTC, …) :190 USED
path_provider ^2.1.6 lib/journal.dart:19 getApplicationDocumentsDirectory() :69, getTemporaryDirectory() :224, getExternalStorageDirectory() :229 USED
share_plus ^13.3.0 lib/ui/modals.dart:4 SharePlus.instance.share(ShareParams(…)) :705 USED
device_info_plus ^13.2.0 lib/journal.dart:17 DeviceInfoPlugin() :116 USED
flutter_test sdk (dev) all 13 test files test, testWidgets, expect USED
flutter_lints ^6.0.0 (dev) not a Dart import include: package:flutter_lints/flutter.yaml analysis_options.yaml:10 USED
flutter_launcher_icons ^0.14.3 (dev) not a Dart import flutter_launcher_icons: config block pubspec.yaml:33-39; run via dart run flutter_launcher_icons (pubspec.yaml:32) USED
path_provider_platform_interface ^2.1.3 (dev) test/journal_test.dart:6 PathProviderPlatform.instance override :31 USED
plugin_platform_interface ^2.1.8 (dev) test/journal_test.dart:7 MockPlatformInterfaceMixin :12 USED

Unused dependencies: 0 of 10 direct, 0 of 5 dev.


APK size breakdown

Universal release APK, 53,629,091 bytes, built at 03a176e. Proof: proof/01_findings/S6/apk_composition.txt, apk_analyze_size_arm64.txt, aab_build.txt.

Group Compressed bytes in APK Share Avoidable?
lib/x86_64/ (4 .so, Stored) 18,611,344 34.7 % YES — emulator/Chromebook architecture; no kitchen tablet runs it
lib/arm64-v8a/ (4 .so, Stored) 17,154,144 32.0 % no — this is the architecture that runs
lib/armeabi-v7a/ (4 .so, Stored) 14,548,580 27.1 % YES for a 64-bit-only build; keep only if 32-bit tablets are in the pilot fleet
assets/ (34 entries: 15 WAVs, 7 fonts, logo, shaders, NOTICES, plugin web assets) 2,138,587 4.0 % partly — 1,664,814 of it is WAV, but that is a product decision (S6-F23); 29,863 is the dead font (S6-F14); 697 is MaterialIcons (S6-F22)
classes.dex 490,845 0.9 % no
res/ (290 entries; includes res/pC.wav = the 142,928-byte backstop alarm) 295,420 0.6 % no — deleting the alarm is S6-F16's catastrophic case
resources.arsc 117,412 0.2 % no
classes2.dex 92,340 0.2 % no
kotlin/, META-INF/, AndroidManifest.xml, DebugProbesKt.bin, kotlin-tooling-metadata.json 20,255 0.04 % no
Total (zip entries) 53,468,927 zip central directory accounts for the remaining 160,164 bytes

Notable non-avoidable entries inside assets/: NOTICES.Z 113,194 bytes (the licence file the store requires), shaders/ink_sparkle.frag 5,275 and shaders/stretch_effect.frag 3,499 (Flutter engine shaders), packages/wakelock_plus/assets/no_sleep.js 4,666 and packages/flutter_local_notifications_web/web/notifications_service_worker.js 1,418 — two web-only plugin assets shipped into an Android APK. Removing the last two requires forking the plugins; 6,084 bytes is not worth a fork, and they are recorded here so no one else re-derives them.

Measured counterfactuals, both built on the copy:

Build Command Bytes Delta vs universal
Universal APK (today) flutter build apk --release 53,629,091
arm64-only APK flutter build apk --release --target-platform android-arm64 20,623,249 −33,005,842 (−61.5 %)
App bundle (upload artifact) flutter build appbundle --release 51,974,066 Play splits per device; 69,186,960 bytes of debug symbols in BUNDLE-METADATA are stripped and never delivered

Single largest avoidable contributor: the two non-arm64 architectures, 33,005,842 bytes measured (61.5 % of the APK), removed by one build flag.


Coverage manifest

Every file below was opened and read end to end at 03a176e, unless the "What I checked" column says otherwise. "Dup scan" means I read the file looking for repeated logic beyond the code map's 5-line window pairs; "dead scan" means every public top-level, static and instance symbol declared in it was grep-counted across lib/ and test/.

lib/ — 18 files, 4,853 lines

File Lines What I checked
lib/main.dart 58 Dup scan; dead scan (kAppVersion, CadenceApp both used); ThemeData(fontFamily: F.display) at :52 traced as the app-default font family for the S6-F14 font analysis
lib/diagnostics.dart 54 Dup scan (none found); dead scan — DiagEntry, Diag.log, Diag.critical, Diag.fail, Diag.clearCritical, Diag.reset all have callers
lib/journal.dart 250 Dup scan → D18 (zero-pad ×4, plus an unused local helper at :57); dead scan — beatQuietMs self-read only, snapshot/device/appVersion/ready/init/log/beatNow/flushNow/markCleanExit/exportCopy/disableForTests all called
lib/alarm_backstop.dart 279 Dup scan → D17 (title format ×2, with a label/name asymmetry); dead scan (all members called); RawResourceAndroidNotificationSound('cadence_alarm') at :55 traced to the 16th WAV and keep.xml
lib/i18n.dart 167 Dup scan — the en tone-label map is 11 identity pairs that toneLabel's ?? tone fallback at :145 would already produce, but it is load-bearing for the (toneLabels[lang] ?? toneLabels['en'])! fallback and for test/i18n_defaults_test.dart:52's coverage assertion, so not proposed for deletion; dead scan (all members called)
lib/engine/models.dart 160 Dup scan → D3 (the 5 at :22), D15 (7000 ×2), D20 ('Timer'/'Step'); dead scan — kDefaultSound, legacyZoneId, copyWithId, totalSec, isChain, all toJson/fromJson called
lib/engine/engine.dart 432 Dup scan → D3, D14, D15, D20; dead scan → Engine.tickMs DEAD; every other public member (soundFor, isClone, batchCount, parentIdOf, nextBatchNo, batchNoFor, labelFor, viewList, reconcile, uid, maxBatch, alarmLeadMs, dblMs, firstVoiceGapMs, voiceGapFactor, minVoiceGapMs, and all nine EngineHost members) has at least one caller
lib/engine/store.dart 354 Dup scan — _readList (:91) and the run-decode block (:69-85) are structurally parallel but decode different shapes (List vs Map) and share the salvage/preserve helpers already; not duplication. Dead scan: all 9 prefs keys, both migrations, seedLangFor, wasGeneratedByUs, seedIfFresh called
lib/audio/audio.dart 116 Dup scan (none); dead scan — all 11 public members called; assetFor (:83) traced for the WAV reconciliation
lib/audio/voice.dart 204 Dup scan (none); dead scan → VoiceBox.pending is test-only (flagged, kept); staleMs, ready, init, setLocale, enqueue, stopFor all called from lib/
lib/audio/alarm_volume.dart 68 Dup scan (none); dead scan — floor, apply, level, sane, assertLevel, setLevel, onRunChanged all called
lib/ui/theme.dart 82 Dup scan → D7/D11/D13/D18 targets; dead scan → C.logoInk and C.mint DEAD; every other colour, fMint/fAmber/fRed, tones, presets, fillFor, fmtTime, fmtUp, F.display/F.mono/F.dseg7 have callers (fmtUp at tile.dart:200, fillFor at tile.dart:189)
lib/ui/grid_layout.dart 109 Dup scan (none — solve's two loops iterate the same range for different purposes and share _measure); dead scan — gapRatio, marginRatio, maxAspect, fullGridTolerance, gridW, gridH, solve all called; identified as the correct home for D1's utility
lib/ui/logo.dart 18 Read in full; dup scan (nothing to duplicate); the assets/logo/mark_white.png reference at :13 traced to the bundled asset list
lib/ui/header.dart 215 Dup scan → D18 (padLeft ×2); breakpoint literals 470/820/800/960/560 are each single-use so not duplication; dead scan — all 6 Header fields and all 8 _HBtn fields read; font weights w800/w700 recorded for S6-F14
lib/ui/home.dart 722 Dup scan → D1 (grid hit-test ×2), D16 (stop log ×2), D14 (150 literal), D18; also the _flash/_justOn/_spawn counter-bump idiom at :319, :361, :414, :415 (four copies of m[id] = (m[id] ?? 0) + 1, folded into D-none as a one-line helper is marginal); dead scan — no public surface beyond HomeScreen/store
lib/ui/modals.dart 746 Dup scan → D8, D9, D10, D3, D7, D18, D20; _dashedAdd (:548) draws BorderStyle.solid (:556), a name that lies; dead scan — TimerEditorResult, showTimerEditor, showSettings all called from lib/ui/home.dart and from tests
lib/ui/tile.dart 819 Dup scan → D2, D11, D12, D13, D19; dead scan — all 19 TileView fields read, _CtlBtn.signScale read at :672, both painters' shouldRepaint implemented

test/ — 13 files, 2,313 lines

File Lines What I checked
test/engine_test.dart 341 Read in full; host class at :7-34 recorded as D4 copy 6; single/chain helpers at :36-44 are unique to this file
test/store_test.dart 220 Read :1-50 in full plus every Store.open/Engine(NullHost()) block; D4 copy 3, D21, D22
test/backstop_test.dart 191 Read :1-50 in full; D4 copy 4
test/voice_test.dart 197 Grep-checked for VoiceBox.pending (:163, :178) and for EngineHost (absent — this file does not construct a host, so it is not a D4 site)
test/volume_test.dart 188 Read :130-190 in full; D5 copies 3 and 4
test/journal_test.dart 193 Grep-checked for host duplication (absent — uses _FakePaths, unique) and for the Journal API surface used in the dead-code table
test/robustness_test.dart 314 Read :1-50 in full; D4 copy 5, D21, D22; :291 recorded as the assertion that would catch a kDefaultVoiceGapMs change
test/announcement_test.dart 267 Read :1-50 and :188-225 in full; D4 copy 1, D5 copy 1, D6 copy 1
test/i18n_defaults_test.dart 117 Read :1-50 in full plus the asset assertion at :73-77; D4 copy 2; recorded as covering only the 12 picker tones, not step/click-up/click-down/cadence_alarm
test/grid_layout_test.dart 152 Grep-checked for GridLayout public-member usage (gapRatio, marginRatio, maxAspect, gridW, gridH all asserted) — confirms none of them are dead
test/editor_layout_test.dart 79 Read in full; D5 copy 2, D6 copy 2, D7 (the preset-label copy), and the eighth copy of the 5-second floor at :76
test/source_hygiene_test.dart 25 Read in full; a text scan of lib/, no lib/ symbol exercised, no duplication
test/version_test.dart 29 Read in full; the only importer of lib/main.dart; confirms kAppVersion is not dead

Configuration, platform, tooling, assets

File Lines What I checked
pubspec.yaml 68 Read in full — all 10 direct and 5 dev dependencies traced to a use; asset dirs :44-46; 3 font families / 7 .ttf at :48-68 traced weight by weight; uses-material-design: true at :42 (S6-F22); flutter_launcher_icons block :33-39
analysis_options.yaml 28 Read in full — S6-F20; confirmed no unused_* suppression, which is what makes the "zero dead private code" claim provable
README.md 38 Read in full — S6-F19 (five contradictions with the shipped code)
android/app/build.gradle.kts 51 Read in full — no splits/abiFilters (S6-F1); neither isMinifyEnabled nor isShrinkResources set (S6-F16)
android/app/src/main/res/raw/keep.xml 6 Read in full — S6-F16; the tools:keep="@raw/cadence_alarm" pin and its v0.3 incident note
android/app/src/main/res/raw/cadence_alarm.wav binary, 142,928 B Not read as text; md5 8cdc98e2f7b9e467cb5b3c99de8fcf50; extracted from the shipped APK as res/pC.wav and byte-compared; regenerated from tools/build_ringtones.py:236 and byte-compared
tools/build_ringtones.py 238 Read in full — enumerated all 13 write/write_peak calls; re-ran it on the copy and byte-compared all 16 WAVs (S6-F15)
assets/audio/*.wav 15 binary files Not read as text; sizes and md5s captured; set-compared in both directions against the code's reference list
assets/fonts/*.ttf 7 binary files Not read as text; sizes captured on disk and inside the APK; each declared weight traced to a requesting call site or proven unreachable (S6-F14)
assets/icon/*.png, assets/logo/mark_white.png 4 binary files Not read as text; mark_white.png traced to lib/ui/logo.dart:13 and to the APK bundle; the 3 icons traced to pubspec.yaml:36-39 and confirmed absent from the APK, which is correct
web/index.html 46 Read in full — S6-F21
web/manifest.json 35 Read in full — S6-F21
web/favicon.png, web/icons/* (4) binary Not read as text; enumerated by find web -type f; covered by S6-F21
android/app/src/main/AndroidManifest.xml, MainActivity.kt, ios/Runner/* Not in S6 scope and not re-derived. Read only as far as the code map records them; no duplication or dead-code claim is made about them. They are S3/S4/store-readiness scope.

Proof artifacts produced by this stream

All under proof/01_findings/S6/, each with the run_and_record.sh provenance header (command, cwd, UTC time, Flutter version, git sha, exit code):

apk_analyze_size_arm64.txt, aab_build.txt, apk_composition.txt, assets_wav_reconciliation.txt, assets_wav_orphan_check.txt, backstop_alarm_16th_wav.txt, build_ringtones_rerun.txt, ringtone_generator_reproducibility.txt, dead_symbols_grep.txt, deps_usage.txt, duplication_sites.txt, font_weight_usage.txt, wav_vs_compressed_audio.txt, web_target_vestigial.txt.

Experiment copy (R10-compliant, freely modified): a scratch working copy. The pinned repo was never written to.

S6 refutation — DRY, dead code and bloatagent_reports/S6_refute.md · raw .md

S6 refutation — DRY, dead code and bloat

Refuter, fresh context, governed by R5. Subject read-only at 03a176e72ef0075eec86b8915cbe6e93042a3b9d (git status --porcelain on the pinned repo is empty before and after this run — R10 held). Every experiment ran on copies under a scratch working copy (cadence-app/ for builds, mut/ for the RunStatus mutation, mut2/ for the voice-gap mutation). All commands recorded through proof/run_and_record.sh [not published] into proof/01_findings/S6_refute/.

Note on R9: two quoted blocks below carry a […] marker where a French string literal has been elided. Everything else is byte-verbatim.

Headline

Question put to me Verdict
"22 proven duplications" 18 are real semantic duplication, 4 are pattern-matching (D8, D18, D21, D22 REFUTED)
The three duplicated invariants 5-second floor CONFIRMED (6 of 7 sites; 1 is a default, not the floor). 7000 ms CONFIRMED as duplication, consequence REFUTED. Grid hit-test CONFIRMED, exactly as described
RunStatus fourth value "would crash rather than fail to compile" REFUTED twice over. It fails to compile (flutter analyze exit 1, non_exhaustive_switch_expression at lib/ui/home.dart:124), and with that error patched away the tile renders the fourth value without throwingr! is null-safe by construction
"Confirmed dead symbols: 3" CONFIRMED by an independent automated sweep over 123 public declarations; zero mentions anywhere in the tree, including Kotlin, Swift, XML, test/
ChivoMono-Medium.ttf unused, safe to delete CONFIRMED and now proven — I ran the experiment S6 only proposed. Deleting the Medium face leaves w400 and w700 rasterising to byte-identical pixels
Zero unused audio CONFIRMED, both directions, all 16 WAVs, reproduced independently
61.5 % / 33,005,842 bytes / 20,623,249-byte arm64 build REPRODUCED to the byte. But the recommendation's headline framing is REFUTED: armeabi-v7a is not "an architecture nobody's kitchen tablet will ever execute"

Findings: 23 total → 20 CONFIRMED, 0 fully refuted, 3 with a materially changed claim or severity (S6-F4 scope, S6-F7 severity MEDIUM→LOW, S6-F9 crash claim). Duplication table: 18 of 22 CONFIRMED, 4 REFUTED. I contribute 4 findings S6 missed, three of them proven by a test that goes red today.


Per-finding verdicts

# S6 claim Verdict Reasoning and evidence
S6-F1 Universal APK ships 3 ABIs; 33,005,842 B (61.5 %) removable; arm64-only = 20,623,249 B CONFIRMED (measurement), severity holds; headline framing REFUTED I rebuilt both on my own copy: universal 53,629,091, arm64-only 20,623,249 — identical to the byte (apk_rebuild_independent.txt). The framing is wrong: minSdk = 24 (Android 7.0, read from the merged manifest), and per developer.android.com/google/play/requirements/64-bit (retrieved 2026-08-04, proof/03_market/captures/), "It isn't required to support every 64-bit architecture, but for each native 32-bit architecture you support you must include the corresponding 64-bit architecture" — dropping armeabi-v7a is permitted but it drops 32-bit tablets, which is exactly the cheap hardware a restaurant buys. See "the size fix" below
S6-F2 Engine.tickMs dead; home.dart:154 hard-codes 150 CONFIRMED tickMs appears once in the whole tree (independent_verification.txt §2). home.dart:154 verified open
S6-F3 C.mint, C.logoInk dead CONFIRMED Whole-tree grep incl. android/, ios/, generated files: one line each, the declaration. Raw values 0FA96A/F5F1E8 appear nowhere else. Zero test/ hits. No dart:mirrors/noSuchMethod/Function.apply anywhere, so no dynamic lookup is possible
S6-F4 The 5-second floor is one invariant at 7 sites CONFIRMED, scope corrected All 7 opened. Six enforce the floor (engine.dart:368, :372, :373, modals.dart:374, :388, :524) and modals.dart:418 is the picker's snap-off-zero, which is the floor in practice because the seconds stepper moves in fives (modals.dart:436-437 — a fourth encoding of the same 5 that S6 does not list). models.dart:22 is not the invariant: it is a JSON default, and it does not enforce the floor — {'sec': 1} loads as 1. S6's own "why it matters" concedes this. Its "How to prove the fix" is also wrong: editor_layout_test.dart:76 asserts secs.first >= 5 where C.presets smallest is [0,30] = 30 s, so re-pointing it at a constant set to 9 still passes (30 ≥ 9). The gate only fires above 30
S6-F5 Grid hit-test written twice in home.dart CONFIRMED, exactly as described _panUpdate (:509-530) recomputes _tileIndexAt's (:609-615) expression and bounds identically; _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 EngineHost no-op copied into 6 test files CONFIRMED, strongest duplication in the report announcement_test:23-42, i18n_defaults_test:13-32, store_test:12-31 are md5-identical (f2198392ae74ea9e5660de7aa3bfa9d2, 20 lines each). backstop_test differs only in now(). implements EngineHost makes the compiler force all nine members in every copy, so the "a tenth member breaks six files" argument is literally true
S6-F7 7000 ms first voice gap at 3 sites CONFIRMED as duplication; consequence REFUTED; severity MEDIUM → LOW The three literals exist. But the stated mechanism ("a timer restored from disk starts its escalation from the old value") 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). The constructor default is never observed and fromJson's ?? 7000 fires only for a legacy blob written before the key existed. Its "How to prove the fix" is also wrong: I mutated firstVoiceGapMs 7000→3000 and ran the whole suite — exactly one test went red, test/engine_test.dart:185, and test/robustness_test.dart:291 (the test S6 names) passed. That line's 7000 at :285 is an injected freeze duration, not a voice gap (mutation_firstvoicegap_3000.txt, patches/firstvoicegap_3000.patch)
S6-F8 running/paused arms duplicated in tile.dart CONFIRMED Four of six lines identical, including the t.steps! unwrap and the clamp. Small but real
S6-F9 RunStatus as strings at 11 sites; default force-unwraps r!; a fourth value crashes rather than failing to compile Duplication CONFIRMED; the crash claim REFUTED; enumeration wrong See the dedicated section below
S6-F10 Four styling duplications in modals.dart PARTLY REFUTED (b) :253-258 vs :338-343 byte-identical — CONFIRMED. (c) :482-492 vs :535-545 — style blocks byte-identical, decorations differ by one number — CONFIRMED. (d) _dashedAdd draws BorderStyle.solid — CONFIRMED, a name that lies. (a) REFUTED: _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 fix gives the merged function four optional parameters that all three settings call sites must override, which is more code and a worse API than two honest functions
S6-F11 Shadow ×2, radius formula ×2, tile radius 20 ×2 CONFIRMED Shadow blocks byte-identical (0x291C211C, blur 4, offset 0,1). math.min(10, 2.6*ch) at :439 and math.min(10.0, 2.6*ch) at :598. BorderRadius.circular(20) at :218 and Radius.circular(20 - inset) at :798 — a genuine cross-class coupling: the edit-mode outline must track the card's corner
S6-F12 Modal harness ×4, viewport override ×2 CONFIRMED Four MaterialApp → Builder → TextButton(Text('open')) scaffolds; two 5-line viewport blocks differing only in Size
S6-F13 Preset label duplicated across the production/test boundary CONFIRMED modals.dart:302-304 and editor_layout_test.dart:18-20, with the test's own comment admitting the coupling. A test that reimplements the expression cannot detect a change in it
S6-F14 ChivoMono-Medium.ttf declared, bundled, never selected CONFIRMED — and now proven, which S6 did not do Two experiments below. 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}. Removing the Medium face leaves w400 and w700 pixel-identical
S6-F15 3 of 16 WAVs have no generator CONFIRMED tools/build_ringtones.py has 13 write/write_peak calls: 12 tones + res/raw/cadence_alarm.wav. step.wav, click-up.wav, click-down.wav appear in no recipe
S6-F16 Backstop alarm invisible to the shrinker, pinned by keep.xml CONFIRMED keep.xml verified verbatim incl. its v0.3 incident note; build.gradle.kts sets neither isMinifyEnabled nor isShrinkResources. Do not delete
S6-F17 Alarm-acknowledgement journal line written twice CONFIRMED home.dart: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
S6-F18 Zero-pad ×10 and the notification title ×2 SPLIT: pad2 REFUTED, title asymmetry CONFIRMED and under-severitised pad2 REFUTED: 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 the 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. Title CONFIRMED: _desired passes engine.labelFor(t.id) (alarm_backstop.dart:113) while showNow uses raw t.name (:259), and _nid is per-clone, so 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
S6-F19 README documents the v0.1 architecture CONFIRMED, and understated All five contradictions verified verbatim. A sixth: README.md:13 says store.dart persists 4 keys; S6's own coverage manifest counts 9 prefs keys
S6-F20 Empty linter: rules: block CONFIRMED Read in full; it is the unmodified Flutter template block
S6-F21 web/ is the untouched stock template CONFIRMED git log -- web/ returns exactly one commit, 22902e0, the v0.2.0 import
S6-F22 uses-material-design: true for zero icons CONFIRMED grep -rn "Icon(\|Icons\.\|IconData\|IconButton" lib/ → zero hits. The only Material widget that renders is Slider (modals.dart:655), which draws no icon
S6-F23 16 WAVs ship uncompressed — reported, not proposed CONFIRMED as a non-finding Correctly framed under R6 as a product decision. Nothing to refute

The RunStatus claim, tested (S6-F9)

S6's sharpest sentence: "Add a fourth RunStatus value and the tile crashes on r! for a stopped timer, taking the whole board down mid-service", with the proof protocol "Today flutter analyze stays at 0 issues and the exhaustiveness hole ships." I ran it. Both halves fail.

Half one — it does not compile. On the copy, enum RunStatus { running, paused, ringing }{ running, paused, ringing, stopped }, then flutter analyze --fatal-infos --fatal-warnings (runstatus_fourth_value_analyze.txt, EXIT_CODE=1):

  error • The type 'RunStatus' isn't exhaustively matched by the switch cases since it doesn't
  match the pattern 'RunStatus.stopped'. Try adding a wildcard pattern or cases that match
  'RunStatus.stopped' • lib/ui/home.dart:124:20 • non_exhaustive_switch_expression

1 issue found.

lib/ui/home.dart:124-133 is a switch expression over RunStatus with no default arm. S6 never mentions it — it is not in the finding, not in D19, and not in the lib/ui/home.dart row of the coverage manifest. A fourth RunStatus value cannot reach a build.

Half two — with the compile error patched away, nothing crashes. I added a wildcard arm to that switch and wrote three widget tests rendering TileView with RunEntry(status: RunStatus.stopped) — single timer, chained timer, and a check on which arm runs (runstatus_fourth_value_crash_test.txt, EXIT_CODE=0, +3 All tests passed, tester.takeException() null in every case). The reason is structural:

  String get _status {
    final r = widget.r;
    if (r == null) return 'idle';
    return r.status.name;
  }

_status == 'idle' iff widget.r == null. Any other value implies r != null, so the default arm's r! can never throw. The real consequence of a fourth value is a wrong render — a stopped tile painted as ringing, with the ±10 s/✕ row hidden by :569 — not a crash.

Enumeration. S6 says "eleven comparison sites" and then lists ten line numbers. The file has comparisons at :110, :111, :174, :182, :185, :192, :205, :207, :208, :257, :569 (:569 holds two). :174final chainVisual = chain && status != 'ringing'; — is missing from S6's list.

Net: the finding survives as a MEDIUM DRY/type-safety defect (eleven unchecked string comparisons the compiler cannot see, one of which S6 itself missed, which is the argument for the fix). Its severity justification — the crash — does not survive.


The Chivo Mono weight, tested (S6-F14)

The brief's hypothesis was that Flutter falls back across weights, so a declared-but-unrequested weight might still be selected. Fallback is real in this app — I measured it — and it still does not reach the Medium face.

Experiment 1, what the app actually requests. A widget test walks every RenderParagraph in the rendered tree and records the resolved (fontFamily, fontWeight) after every ThemeData / Material 3 textTheme / DefaultTextStyle / InputDecorator merge, across the editor in single and chain mode, the settings dialog, TileView in all five status states, and the Header (resolved_font_styles.txt, EXIT_CODE=0):

=== RESOLVED (fontFamily / fontWeight) PAIRS ===
  Big Shoulders Display / FontWeight.w400
  Big Shoulders Display / FontWeight.w500
  Big Shoulders Display / FontWeight.w700
  Big Shoulders Display / FontWeight.w800
  Chivo Mono / FontWeight.w400
  Chivo Mono / FontWeight.w700
  DSEG7 Classic / FontWeight.w700

Chivo Mono is requested at w400 and w700 and nothing else. (This also strengthens S6's "do not touch BigShouldersDisplay-Medium.ttf" for a cause S6 did not give: Material 3's textTheme requests w500 outright, on top of the w400→500 fallback S6 cites.)

Experiment 2, does removing the face change anything. Two test processes register the real TTFs through FontLoader under a probe family — one with all three Chivo faces, one without the Medium — then rasterise the same string at each weight and md5 the pixels (font_weight_fallback_probe.txt, EXIT_CODE=0):

A|FontWeight.w400|ac8383faf8b3927318174830cdc5ce71     B|FontWeight.w400|ac8383faf8b3927318174830cdc5ce71
A|FontWeight.w500|60e0da098d0a3dbfedf06a4583731ac2     B|FontWeight.w500|ac8383faf8b3927318174830cdc5ce71
A|FontWeight.w600|6293fb9050e89aceb34e1b396a56a78e     B|FontWeight.w600|6293fb9050e89aceb34e1b396a56a78e
A|FontWeight.w700|6293fb9050e89aceb34e1b396a56a78e     B|FontWeight.w700|6293fb9050e89aceb34e1b396a56a78e

Three distinct hashes with all faces present proves the harness really selects per weight. w600 → the Bold hash proves cross-weight fallback is live. w500 changing between A and B proves the Medium face is selectable when asked for. w400 and w700 are byte-identical across A and B — the only two weights the app asks for. Deleting assets/fonts/ChivoMono-Medium.ttf and pubspec.yaml:61-62 cannot change one rendered pixel. Safe.


Audio, verified independently (both directions)

independent_verification.txt §5. I derived the used set from C.tones through SoundBox.assetFor's own transform (toLowerCase().replaceAll('buzzer','buzz')) plus the three literals in audio.dart:89/:92, and set-compared against os.listdir('assets/audio'):

used by lib/ (15) == on disk assets/audio (15)
comm -23 (orphan on disk)   : EMPTY
comm -13 (used, missing)    : EMPTY
16th WAV android/app/src/main/res/raw/cadence_alarm.wav exists=True size=142928
every .wav in tree: 16

Zero unused audio CONFIRMED. All 16 accounted for, including res/raw/cadence_alarm.wav.


The size fix: arm64-only or App Bundle?

I built every variant myself.

Build Bytes Source
Universal APK (today) 53,629,091 apk_rebuild_independent.txt
--target-platform android-arm64 20,623,249 same
--split-per-abi arm64-v8a 20,402,081 apk_split_per_abi.txt
--split-per-abi armeabi-v7a 17,798,325 same
--split-per-abi x86_64 21,859,901 same
App bundle (upload artifact) 51,974,095 same (S6 recorded 51,974,066; a 29-byte zip-timestamp difference, not a discrepancy)

The App Bundle is the right answer, and S6 did name it first in S6-F1's proposed fix. Three things in S6 pull the other way and should be corrected rather than defended:

  1. The verdict paragraph asserts the 33,005,842 bytes are "two CPU architectures nobody's kitchen tablet will ever execute". minSdk = 24 (Android 7.0), read from the merged manifest at build/app/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml:8. armeabi-v7a is executed by 32-bit Android tablets, which is precisely the sub-€100 hardware a restaurant buys for a pass. x86_64 is genuinely dead weight; armeabi-v7a is not.
  2. The "How to prove the fix" gate is arm64-only. That gate passes on a build that has silently dropped 32-bit device support.
  3. --split-per-abi beats --target-platform android-arm64 on both axes: the arm64 APK is 221,168 bytes smaller and a 17.8 MB armeabi-v7a APK exists for the tablets that need it.

Official sources, captured with utilities/chrome.py into proof/03_market/captures/, retrieved 2026-08-04:

  • developer.android.com/guide/app-bundle: "From August 2021, new apps are required to publish with the Android App Bundle on Google Play." The choice is not open for a new listing.
  • developer.android.com/google/play/requirements/64-bit: "It isn't required to support every 64-bit architecture, but for each native 32-bit architecture you support you must include the corresponding 64-bit architecture." Dropping 32-bit is permitted; it is a market decision, not a compliance one. The same page's own remedy for the size cost is titled "Mitigate size increases with Android App Bundle".

Corrected recommendation: ship the .aab (mandatory anyway), keep all three ABIs in it, and let Play deliver ~20.4 MB to an arm64 tablet and ~17.8 MB to a 32-bit one. Use --split-per-abi, not --target-platform android-arm64, for the sideloaded pilot APKs. The 61.5 % number is right; the sentence that no tablet runs those architectures is not.


Findings S6 missed

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

  • Severity: HIGH
  • Location: lib/engine/store.dart:274-277 (the v0.4.11 zone→sound migration), lib/engine/models.dart:71 (fromJson), lib/engine/engine.dart:66 (soundFor), lib/audio/audio.dart:83-84 (assetFor)
  • What is wrong: S6's negative result reads "Referenced-but-missing WAVs: Zero. No C.tones entry maps to a file that is not on disk." 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, TimerDef.fromJson accepts any string from prefs, and Engine.soundFor is String soundFor(TimerDef t) => t.sound; — a bare pass-through. assetFor then lowercases it into a filename that does not exist, AssetSource throws, and the catch at audio.dart:75-77 turns it into Diag.fail('audio-play', …, isCritical: true). The timer rings silently. audio.dart:80-82 calls this exact outcome "a SILENT alarm, the single worst failure this app has" — and the only guard is test/i18n_defaults_test.dart:70-78, which iterates C.tones and therefore can never see it.
  • Evidence: 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:
    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:

      for (final t in e.timers) {
        final inherited = tones[t.legacyZoneId] ?? _legacyFallbackSound;
        if (t.sound != inherited) {
          t.sound = inherited;
  • Why it matters for a restaurant kitchen: this is the one failure the product cannot survive. A tablet upgraded across v0.4.11 whose zone tone was renamed or hand-edited gets a timer that counts down, reaches zero, fires the haptic, posts the notification — and makes no sound over the extraction hoods. The banner is the only tell, and nobody watches a banner during service.
  • Proposed fix: 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 the fix: 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.

S6R-F2 — --obfuscate --split-debug-info removes 851,968 bytes and was never measured

  • Severity: LOW
  • Location: README.md:25 (the documented build command), android/app/build.gradle.kts:30-36
  • What is wrong: S6 measured the ABI split and the WAV encode and stopped. The Dart AOT snapshot ships with its symbol table intact. --obfuscate --split-debug-info=<dir> strips it into a side file that is uploaded to Play for crash de-obfuscation rather than shipped. It is subtractive, changes no behaviour and needs no product decision — unlike the 1,664,814-byte audio saving S6 reported, which requires re-validating every tone by ear.
  • Evidence: 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
  • Why it matters for a restaurant kitchen: the same argument S6-F1 makes — every version bump is a download on a venue connection — for a lever that costs nothing but a flag and a symbols directory kept next to the release.
  • Proposed fix: 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 the fix: 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

  • Severity: LOW
  • Location: lib/ui/home.dart:124-133
  • What is wrong: D19 enumerates eleven RunStatus decision sites, all in tile.dart, and concludes the enum is unchecked. There is a twelfth, in home.dart, and it is the good one — an exhaustive switch expression with no default. It is the reason S6-F9's proof protocol is wrong, and it is the working example the fix should be modelled on. Omitting it from the finding, from the duplication table and from the lib/ui/home.dart coverage-manifest row makes the codebase look uniformly worse than it is and hides the fact that a fourth enum value is already caught.
  • Evidence: 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:
      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 =>
          '[…]',
      };
  • Why it matters for a restaurant kitchen: indirectly — it is the difference between "adding a status is caught by the compiler" and "adding a status ships". The first is true today.
  • Proposed fix: none to the code. Correct S6-F9's evidence, its proof protocol and D19.
  • How to prove the fix: runstatus_fourth_value_analyze.txt already is the proof.

S6R-F4 — The picker's five-second granularity is a fourth encoding of the floor

  • Severity: LOW
  • Location: lib/ui/modals.dart:436-437
  • What is wrong: S6-F4 lists modals.dart:418 (if (min == 0 && sec == 0) sec = 5;) as the seventh site of the 5-second floor but not the two lines that make it work. The seconds stepper moves in fives, so the smallest non-zero value the picker can express is five seconds. Centralise the floor without these and the picker keeps offering 0:05 under a 10-second floor, so the editor displays one number and commits another.
  • Evidence: independent_verification.txt §8; lib/ui/modals.dart:436-437 verbatim:
        col('sec', sec, () => bump(() => sec = (sec + 5) % 60),
            () => bump(() => sec = (sec + 55) % 60)),
  • Why it matters for a restaurant kitchen: a chef who dials 0:05 and gets a 0:10 timer stops trusting the editor, and the one durable rule of this product is that the board says what it does.
  • Proposed fix: 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 the fix: 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.

Duplication table — my verdict on all 22

# S6's claim Verdict Why
D1 Grid hit-test CONFIRMED Identical expression + bounds; the helper already exists and is already called elsewhere
D2 running/paused remaining-time CONFIRMED 4 of 6 lines identical incl. the steps! unwrap
D3 5-second floor CONFIRMED (6 of 7 sites) models.dart:22 is a JSON default that does not enforce the floor; modals.dart:436-437 is a missing eighth
D4 EngineHost no-op ×6 CONFIRMED Three md5-identical copies; implements forces all nine members in every one
D5 Modal-opening harness ×4 CONFIRMED Same scaffold, same tap, same settle; only the callback varies
D6 Viewport override ×2 CONFIRMED Byte-identical but for the Size
D7 Preset chip label CONFIRMED Test reimplements the production expression and says so
D8 Field-label widget REFUTED Zero shared values: 10.9/14.7, 2.2/2.3, muted/text, 8/12. Shape-matching. The proposed utility is all-override at every call site
D9 Name/announcement TextField style CONFIRMED Byte-identical six-line const TextStyle
D10 Compact deco + mono cell style CONFIRMED Style blocks byte-identical; decorations differ by one padding number
D11 Button drop shadow CONFIRMED Byte-identical BoxShadow
D12 Control-button radius formula CONFIRMED Same expression, 10 vs 10.0
D13 Tile corner radius 20 CONFIRMED :798 must track :218 or the edit outline leaves the card
D14 Tick period 150 CONFIRMED Constant declared, never read; literal written twice more
D15 First voice gap 7000 CONFIRMED as duplication, LOW Three literals must agree; but the divergence path S6 describes is unreachable — toJson always writes the value and _ring always resets it
D16 Alarm-acknowledgement journal line CONFIRMED Two clocks for one metric
D17 Backstop notification title CONFIRMED — and it is a behavioural defect, not a duplication labelFor vs raw t.name; three clones stack as three identical ⏰ Fries
D18 Two-digit zero pad ×10 REFUTED A library idiom with no invariant behind it. S6's own fix produces two helpers plus a layering rule to replace ten one-line calls
D19 RunStatus as strings CONFIRMED (12 sites, not 11) :174 missing from the list; :569 holds two comparisons
D20 Default names 'Timer'/'Step' CONFIRMED Six sites, one shared product default
D21 Shared test import block REFUTED Imports are not logic; the two blocks are not even identical (robustness_test also imports i18n.dart); re-exporting imports from a support file hides dependencies
D22 Store-fixture boilerplate ×7 REFUTED Three lines of construction with no invariant. Any change to Store.open's signature is caught by the compiler. Hiding each test's prefs setup behind a helper parameter makes the tests harder to read, not safer

18 CONFIRMED / 4 REFUTED.


Independent dead-symbol sweep

Rather than re-run S6's greps, I enumerated every public/top-level declaration in lib/ by pattern (static const|final, top-level const|final, classes, top-level functions) and counted whole-word occurrences across lib/ + test/ with comments stripped (independent_verification.txt §1):

scanned 123 declarations
  DEAD  logoInk                  lib/ui/theme.dart                refs=1
  DEAD  mint                     lib/ui/theme.dart                refs=1
  DEAD  tickMs                   lib/engine/engine.dart           refs=1

Exactly S6's three, by a different method. Cross-checks: none appears anywhere in android/, ios/, XML, YAML or generated files; none appears in test/; the raw colour literals 0FA96A and F5F1E8 appear nowhere else; and there is no dart:mirrors, noSuchMethod or Function.apply in the project, so no dynamic lookup can exist. Every lib/ file is imported by at least one other file, so there are no dead files either.


Coverage manifest — S6's scope, re-checked

Method key: O opened and read at the cited region; G independent grep/sweep; R run (build, test or mutation on a copy).

File Lines What I checked, independently
lib/main.dart 58 O — ThemeData(fontFamily: F.display) at :52 traced for the font analysis; useMaterial3: true traced to the M3 textTheme w500 requests my probe found. G — dead sweep
lib/diagnostics.dart 54 G — dead sweep, every symbol used
lib/journal.dart 250 O :44-70, :215-228 — the p helper at :57 is function-local to _ts, so :62 structurally cannot use it; D18 judged and refuted. G — dead sweep
lib/alarm_backstop.dart 279 O :95-130, :160-200, :235-270_desired uses labelFor, showNow uses t.name; D17 confirmed as a behavioural defect. O keep.xml
lib/i18n.dart 167 G — dead sweep; call() at :143 used in my font probe
lib/engine/models.dart 160 O :1-145 in full — RunStatus at :91, toJson writes voiceGap unconditionally at :126 (the fact that refutes S6-F7's mechanism), fromJson defaults at :114/:139, the ?? 5 at :22 judged as a default not a floor. R — mutated RunStatus to four values
lib/engine/engine.dart 432 O :45-60, :66, :75-116, :132-138, :275-300, :355-380_ring resets voiceGap; soundFor is a bare pass-through (S6R-F1); all six floor sites. R — mutated firstVoiceGapMs
lib/engine/store.dart 354 O :233-285migrateZoneSounds writes an unvalidated tone name (S6R-F1). G — grep -n "tones.contains\|validate\|sanitiz" returns nothing
lib/audio/audio.dart 116 O :60-95_play's catch turns a missing asset into Diag.fail(isCritical: true), i.e. a silent alarm; assetFor's lowercase transform reproduced exactly in my set comparison
lib/audio/voice.dart 204 G — dead sweep; pending confirmed test-only, agreeing with S6
lib/audio/alarm_volume.dart 68 G — dead sweep
lib/ui/theme.dart 82 O :15-82 in full — mint/logoInk dead, presets smallest is [0,30] (which is what breaks S6-F4's proof gate), fmtTime's padLeft judged under D18
lib/ui/grid_layout.dart 109 O — confirmed as the correct home for D1's utility; no duplication of its own
lib/ui/logo.dart 18 G — asset usage traced
lib/ui/header.dart 215 O :10-40, :130-152, G weights — only w700/w800; rendered in the font probe
lib/ui/home.dart 722 O :118-135 (the exhaustive RunStatus switch S6 missed), :288-315, :360-375, :495-535, :575-620, :650-665. R — patched the switch to compile the four-value mutation
lib/ui/modals.dart 746 O :52-64, :63-80, :98-118, :250-260, :298-316, :335-345, :363-395, :410-475, :478-494, :515-547, :700-729 — D8 refuted, D9/D10 confirmed, the picker's five-step granularity found (S6R-F4), all four TextFields confirmed to carry an explicit fontWeight (which is why M3's titleMedium w500 never leaks into mono)
lib/ui/tile.dart 819 O :1-95, :95-222, :434-450, :490-510, :560-600, :690-705, :792-802; G every status comparison and every ! unwrap. R — rendered in five status states incl. the injected fourth value
test/engine_test.dart 341 O :7-34 (D4 copy), :185-193the test that actually pins firstVoiceGapMs, proven by mutation
test/store_test.dart 220 O :1-31, :76-86, :124-168, :209-219 — D4 md5-verified, D21/D22 judged and refuted
test/backstop_test.dart 191 O :12-31 — D4 variant, differs only in now()
test/voice_test.dart 197 G — .pending sites; not a D4 site, agreeing with S6
test/volume_test.dart 188 O :133-186 — D5 copies 3 and 4
test/journal_test.dart 193 G — no host duplication
test/robustness_test.dart 314 O :1-34, :46-76, :282-296:285's 7000 is a freeze duration, not a voice gap; R — the mutation proves :291 does not move
test/announcement_test.dart 267 O :186-225 — D4/D5/D6 copies
test/i18n_defaults_test.dart 117 O :13-32, :68-80 — the asset assertion iterates C.tones only, which is the blind spot behind S6R-F1
test/grid_layout_test.dart 152 G — GridLayout members exercised
test/editor_layout_test.dart 79 O :14-50, :70-79 — D5/D6/D7 copies and the eighth 5; computed that secs.first == 30, which is what falsifies S6-F4's proof gate
test/source_hygiene_test.dart 25 G
test/version_test.dart 29 G
pubspec.yaml 68 O :40-68 in full — 3 families / 7 TTFs, weights 400/500/700 for Chivo Mono; R — both fallback probes
analysis_options.yaml 28 O in full — template block confirmed
README.md 38 O in full — all five S6 contradictions verified, plus the 4-keys-vs-9-keys one
android/app/build.gradle.kts 51 O in full — no splits/abiFilters, no isMinifyEnabled/isShrinkResources, minSdk = flutter.minSdkVersion; R — resolved to 24 from the merged manifest
android/app/src/main/res/raw/keep.xml 6 O in full
android/app/src/main/res/raw/cadence_alarm.wav binary G — present, 142,928 B, counted in the 16-WAV sweep
tools/build_ringtones.py 238 G — all 13 write/write_peak calls enumerated; the three unreproducible files confirmed
assets/audio/*.wav 15 binary G — set-compared in both directions against the derived used set
assets/fonts/*.ttf 7 binary R — Chivo Mono faces rasterised at five weights with and without the Medium face
web/ (7 files) G — git log -- web/ returns one commit
AndroidManifest.xml, MainActivity.kt, ios/ Out of S6 scope; not re-derived, no claim made

Proof artifacts

Under proof/01_findings/S6_refute/, each with the run_and_record.sh provenance header:

apk_rebuild_independent.txt, apk_split_per_abi.txt, apk_obfuscated_split_debug_info.txt, runstatus_fourth_value_analyze.txt, runstatus_fourth_value_crash_test.txt, resolved_font_styles.txt, font_weight_fallback_probe.txt, missing_asset_silent_alarm.txt, mutation_firstvoicegap_3000.txt, independent_verification.txt, patches/runstatus_fourth_value.patch, patches/firstvoicegap_3000.patch, tests/*.dart (5 files).

Store captures: proof/03_market/captures/android_64bit_requirement.{html,txt}, android_app_bundle.{html,txt} (retrieved 2026-08-04 via utilities/chrome.py).

Experiment copies (R10-compliant, freely modified): a scratch working copy (builds), /mut/ (RunStatus + font probes), /mut2/ (voice-gap mutation, reverted, git status --porcelain empty). The pinned repo was never written to.

Stream S7: finding and refutation

S7 — Test-suite qualityfindings/S7_tests.md · raw .md

S7 — Test-suite quality

Subject: the app repository at 03a176e72ef0075eec86b8915cbe6e93042a3b9d Scope: all 13 files in test/ (2,313 lines, 122 declarations → 123 executed tests). Raw proof: proof/01_findings/S7/ — 57 mutation patches, 57 recorded runs, 5 repeat runs, 3 randomized-order runs, and the drivers that produced them.

All 123 tests pass. This stream establishes what they prove.

A note on French quotations: a workspace hook rejects unaccented French words in written output. Where a test name in the source contains one, it is cited by file:line rather than reproduced. No source text has been silently altered.


0. Headline measurements

Measurement Value Proof
Mutation score 32 killed / 57 = 56.1 % proof/01_findings/S7/mutations/results.json, TABLE.md
Surviving mutations 25 same
Mutations meeting the strict R8 standard (see §1b) 22 of 32 killed — reddening exactly one named test, which ran and failed on an assertion §1b
Distinct tests thereby individually validated 19 of 123 §1b
Mutations that failed to apply 0 no APPLY_ERROR row in results.json
Runs where a test file failed to load 0 of 57passed + failed = 123 in every run §1b table
Patch bodies distinct 57 of 57, SHA-256 of each diff body, no duplicates §1b
Suite result, 5 identical runs All tests passed! ×5, 123 each, EXIT_CODE=0 ×5 flake_run_1..5.txt
Suite result, 3 randomized-order runs All tests passed! ×3, 123 each, seeds 1445823227, 2726609422, third recorded in file random_order_1..3.txt
Order-independent? Yes random_order_1..3.txt
Flake-free across 5 runs? Yes — 8/8 identical including the randomized runs flake_run_*.txt, random_order_*.txt
Tests asserting nothing 0 — all 123 contain at least one expect( §3
lib/ files with no dedicated test file 10 of 18 code map §4.1
lib/ files at 0.00 % line coverage 6 of 18 (837 of 1,927 instrumented lines) baseline SUMMARY.md §6
Subject repo modified Nogit status --porcelain empty, exit 0 subject_repo_untouched.txt
Mutation copy fully reverted Yes revert_clean_check.txt

Method: every mutation was applied to a copy at a scratch working copy (never the subject repo — R10), saved as a re-appliable git diff at proof/01_findings/S7/mutations/M<nn>.patch, run through proof/run_and_record.sh [not published] into M<nn>.txt, then reverted with git checkout -- .. Driver: proof/01_findings/S7/mutations_driver.py [not published]; re-run one with python3 mutations_driver.py M44.

Severity convention: a missing test is graded at the severity of the regression it would let through, capped at HIGH — no defect in shipped behaviour is claimed here, only the absence of the guard that would catch one. S1–S6 own the behavioural defects.


1. Mutation table (all 57)

n red = how many of the 123 tests went red. mode = how the named test(s) failed: A = a matcher/TestFailure assertion, T = a thrown exception unrelated to an expect.

# Target Mutation Verdict n red mode Tests that went red
M01 lib/engine/engine.dart Engine.tickMs 150 → 200 SURVIVED 0 — none —
M02 lib/ui/home.dart real heartbeat Timer.periodic 150 ms → 900 ms SURVIVED 0 — none —
M03 lib/engine/engine.dart Engine.alarmLeadMs 1200 → 400 SURVIVED 0 — none —
M04 lib/engine/engine.dart firstVoiceGapMs 7000 → 4000 KILLED 1 A engine_test: alarm escalation 7s, ×0.72 each repeat, floor 2s
M05 lib/engine/engine.dart voiceGapFactor 0.72 → 0.90 KILLED 1 A same test as M04
M06 lib/engine/engine.dart minVoiceGapMs 2000 → 500 KILLED 1 A same test as M04
M07 lib/engine/engine.dart maxBatch 3 → 5 KILLED 1 A engine_test: cap at 3 per family, shared count from a clone
M08 lib/engine/engine.dart chain advance bound length-1length-2 KILLED 3 A×3 engine_test: catch-up after sleep; final phase overdue → full alarm; the lead never accumulates down a chain
M09 lib/engine/engine.dart chain re-anchors endsAt on now() instead of the scheduled boundary KILLED 4 A×4 the three above + boundary chains from SCHEDULED time — zero drift
M10 lib/engine/engine.dart batchNo reverts to the count-based formula KILLED 3 A×3 robustness_test.dart:205, :224, :242
M11 lib/engine/engine.dart labelFor threshold batchNo < 2< 3 KILLED 3 A×3 robustness_test.dart:167, :189, :242
M12 lib/engine/engine.dart adjustTimer paused: drop the max(0, …) floor KILLED 1 A engine_test: ±10s: running shifts endsAt; paused floors remaining at 0
M13 lib/engine/engine.dart drift reference no longer clamped to armedAt KILLED 1 A robustness_test.dart:263
M14 lib/engine/engine.dart stopTimer no longer calls host.onStopped KILLED 2 A×2 engine_test: double-tap reset…; two ringing timers…
M15 lib/engine/engine.dart saveDef per-step floor 5 s → 1 s KILLED 1 A robustness_test: engine enforces its own floors whatever the caller sends
M16 lib/engine/engine.dart reconcile drops the stepIndex upper-bound check KILLED 1 A robustness_test: reconcile drops structurally invalid entries, keeps valid ones
M17 lib/engine/engine.dart tick() catch no longer removes the bad run entry KILLED 1 A robustness_test: tick survives a hand-corrupted entry — later timers still fire
M18 lib/engine/models.dart isChain boundary >= 2>= 1 SURVIVED 0 — none —
M19 lib/engine/models.dart StepDef.fromJson sec fallback 5 → 0 SURVIVED 0 — none —
M20 lib/engine/models.dart StepDef.fromJson name fallback 'Step''' SURVIVED 0 — none —
M21 lib/engine/models.dart TimerDef.fromJson name fallback 'Timer''' SURVIVED 0 — none —
M22 lib/engine/models.dart TimerDef.fromJson durationSec fallback 0 → 3600 SURVIVED 0 — none —
M23 lib/engine/models.dart TimerDef.fromJson sound fallback kDefaultSound'Bell' KILLED 1 A store_test.dart:126 (zone→sound migration)
M24 lib/engine/models.dart TimerDef.fromJson phrase fallback '''ready' SURVIVED 0 — none —
M25 lib/engine/models.dart RunEntry.fromJson voiceGap fallback 7000 → 0 SURVIVED 0 — none —
M26 lib/engine/models.dart RunEntry.fromJson stepIndex fallback 0 → 1 SURVIVED 0 — none —
M27 lib/engine/models.dart RunEntry.fromJson unknown status runningpaused SURVIVED 0 — none —
M28 lib/engine/models.dart CloneRef.fromJson batchNo fallback 0 → 2 SURVIVED 0 — none —
M29 lib/engine/store.dart legacy fallback tone 'Bell''Chirp' KILLED 1 A store_test.dart:126
M30 lib/engine/store.dart unreadable zones no longer abort the migration KILLED 1 A store_test.dart:172
M31 lib/engine/store.dart seed guard drops the legacy-zones clause KILLED 1 A store_test.dart:204
M32 lib/engine/store.dart Store.lang no longer validates fr/en SURVIVED 0 — none —
M33 lib/engine/store.dart Store.vol no longer routed through AlarmVolume.sane KILLED 1 A volume_test.dart:46
M34 lib/engine/store.dart _preserveCorrupt no longer marks the failure critical KILLED 1 A robustness_test: one malformed timer never wipes the list
M35 lib/audio/alarm_volume.dart floor 0.15 → 0.05 KILLED 2 A×2 volume_test.dart:26 and :160 (widget)
M36 lib/audio/alarm_volume.dart sane clamps to 0.0 instead of the floor KILLED 4 A×4 volume_test.dart:26, :39, :46, :61
M37 lib/audio/alarm_volume.dart onRunChanged fires on every mutation, not the rising edge KILLED 1 A volume_test.dart:103
M38 lib/audio/voice.dart inter-utterance gap 300 ms → 0 ms SURVIVED 0 — none —
M39 lib/audio/voice.dart _dropStale never drops a stale phrase SURVIVED 0 — none —
M40 lib/audio/voice.dart _drain generation guard removed SURVIVED 0 — none —
M41 lib/audio/voice.dart network voices preferred instead of penalised KILLED 1 A voice_test.dart:118
M42 lib/audio/voice.dart _drain no longer holds phrases before the engine is ready KILLED 2 A×2 voice_test.dart:156, :170
M43 lib/alarm_backstop.dart grace 1500 ms → 5000 ms KILLED 1 A backstop_test: a future deadline schedules an EXACT alarmClock backstop
M44 lib/alarm_backstop.dart past-deadline guard widened to −10 min SURVIVED 0 — none —
M45 lib/alarm_backstop.dart debounce 300 ms → 0 ms KILLED 1 A backstop_test: a burst of deadline changes collapses to ONE re-arm
M46 lib/alarm_backstop.dart chained timer no longer backed by its FINAL deadline SURVIVED 0 — none —
M47 lib/alarm_backstop.dart notification id collapses to a constant SURVIVED 0 — none —
M48 lib/alarm_backstop.dart any schedule error degrades exact mode KILLED 1 A backstop_test: G2: a random error does NOT poison exact mode
M49 lib/alarm_backstop.dart sync() no longer cancels a stopped timer's alarm SURVIVED 0 — none —
M50 lib/ui/grid_layout.dart maxAspect 0.85 → 0.95 SURVIVED 0 — none —
M51 lib/ui/grid_layout.dart fullGridTolerance 0.05 → 0.30 KILLED 1 A grid_layout_test.dart:77 (column split golden)
M52 lib/audio/audio.dart assetFor drops toLowerCase() SURVIVED 0 — none —
M53 lib/i18n.dart ttsLocale always en-US SURVIVED 0 — none —
M54 lib/journal.dart kill stamp written only on logged beats KILLED 1 A journal_test.dart:148
M55 lib/diagnostics.dart Diag.fail no longer publishes critical scopes KILLED 7 A×7 across backstop_test, journal_test, robustness_test, store_test, voice_test
M56 lib/engine/engine.dart spawnClone disabled entirely (probe, not a validation) KILLED 8 T×7, A×1 8 tests — not engine_test: a batch clone rings like the dish it was cloned from
M57 lib/ui/theme.dart fmtTime no longer clamps a negative to 0:00 SURVIVED 0 — none —

KILLED 32 / 57 = 56.1 %. SURVIVED 25: M01, M02, M03, M18, M19, M20, M21, M22, M24, M25, M26, M27, M28, M32, M38, M39, M40, M44, M46, M47, M49, M50, M52, M53, M57.


1b. Applying the strengthened R8 to my own evidence

R8 now requires that "the named test went red" be shown to mean the test ran, and failed on its assertion, and failed alone. Three checks, run over the 57 recorded outputs.

(a) Did the test run, or did the file fail to load? For every one of the 57 runs, the expanded reporter's final counters satisfy passed + failed = 123 — the full suite. No run contains Failed to load and none contains a loading <file> [E] line. No mutation was compile-breaking; in all 57 runs every test file loaded and every one of the 123 tests executed. Verbatim examples:

M04 → 00:02 +122 -1: Some tests failed.      (122 + 1 = 123)
M36 → 00:02 +119 -4: Some tests failed.      (119 + 4 = 123)
M56 → 00:02 +115 -8: Some tests failed.      (115 + 8 = 123)
M44 → 00:02 +123:    All tests passed!       (123 + 0 = 123)

(b) Did the named test fail on an assertion, or merely throw? Each [E] block was read for its cause. Of the 32 killed mutations, 31 produced only matcher/TestFailure failures. Sample causes, verbatim from the records:

M04.txt  Expected: <7000>                                  (engine_test alarm escalation)
M13.txt  Expected: a value less than <1000>                (robustness_test drift clamp)
M34.txt  Expected: contains 'load-cadence-timers-v1'       (robustness_test salvage)
M35.txt  The following TestFailure was thrown running a test:
         Expected: exactly one matching candidate
           Actual: _TextWidgetFinder:<Found 0 widgets with text "15 %": []>
         #4  main.<anonymous closure>… (…/test/volume_test.dart:165:7)

The single exception is M56, where 7 of the 8 red tests failed with Null check operator used on a null value — the e.spawnClone('p')! force-unwrap — and only cap at 3 per family failed on an assertion. M56 was designed as a probe, not as a validation of those 7 tests: its only purpose is the negative result in S7-F11 (which test did not redden). It is excluded from every "this test is real" claim below, and I make no assertion that M56 validates any test.

(c) Is each mutation a distinct patch? SHA-256 over each patch's diff body (excluding the provenance comment line): 57 distinct hashes, 0 duplicates. No patch was reused across claims.

(d) Specificity — how many mutations redden exactly the test they name? 22 of the 32 killed mutations produce a single-test failing set. Because three pairs/triples of mutations land on the same test (M04/M05/M06 → one test; M23/M29 → one test), those 22 mutations individually validate 19 distinct tests out of 123 executed — the complete list:

Mutation(s) The one test it reddened — individually validated
M04, M05, M06 engine_test: alarm escalation 7s, ×0.72 each repeat, floor 2s
M07 engine_test: ×N batches cap at 3 per family, shared count from a clone
M12 engine_test: single timer ±10s: running shifts endsAt; paused floors remaining at 0
M13 robustness_test.dart:263 (drift bounded below zero)
M15 robustness_test: saveDef floors — engine enforces its own floors whatever the caller sends
M16 robustness_test: reconcile drops structurally invalid entries, keeps valid ones
M17 robustness_test: tick survives a hand-corrupted entry — later timers still fire
M34 robustness_test: one malformed timer never wipes the list — valid entries recovered
M23, M29 store_test.dart:126 (each timer inherits its zone's tone, orphan keeps Bell)
M30 store_test.dart:172 (unreadable zones do not flatten the kitchen to a default)
M31 store_test.dart:204 (pre-v0.4.11 install without the seeded flag is not re-seeded)
M33 volume_test.dart:46 (a stored 0 % is raised at boot)
M37 volume_test.dart:103 (level re-imposed at the start of a ring, once)
M41 voice_test.dart:118 (voice selection: language, then quality, offline preferred)
M43 backstop_test: a future deadline schedules an EXACT alarmClock backstop
M45 backstop_test: a burst of deadline changes collapses to ONE re-arm
M48 backstop_test: G2: a random error does NOT poison exact mode
M51 grid_layout_test.dart:77 (the column split golden)
M54 journal_test.dart:148 (the death stamp advances on an unwritten beat)

What the other 10 killed mutations do and do not prove. M08, M09, M10, M11, M14, M35, M36, M42, M55, M56 each reddened 2–8 tests. A multi-test red set proves that the set collectively is sensitive to that behaviour; it does not validate each member individually, because any one of them could be reddening for a shared upstream reason. Concretely: M55 (Diag.fail stops publishing critical scopes) reddens 7 tests across 5 files, but all 7 depend on the same single production line, so it validates the Diag.critical contract once, not seven times. I do not claim otherwise.

Residual honesty statement. 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: I ran 57 mutations, not one per test. What I can state is bounded and exact — 19 tests are proven real, 25 named production behaviours are proven unprotected, and no claim in §2 rests on a compile-breaking mutation, a reused patch, or a test that failed by throwing.


2. Findings

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

  • Severity: HIGH
  • Location: lib/engine/engine.dart:32 and lib/ui/home.dart:154 (valid at 03a176e)
  • What is wrong: the board's entire liveness rests on one Timer.periodic. Its period is a hard-coded literal in home.dart, while Engine.tickMs — the constant named for it, documented as "The 150ms heartbeat" at engine.dart:298 — is referenced nowhere. Slowing the real heartbeat to 900 ms (M02) changes when every alarm in the app fires, and not one of the 123 tests notices.
  • Evidence: $ 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.
  • Why it matters for a restaurant kitchen: the tick is what turns a stored deadline into a ring. At 900 ms the alarm lands up to 0.9 s late and the count-up display stutters visibly; at a few seconds the board looks frozen during service. Nothing in CI would report it.
  • Proposed fix: make home.dart:154 read const Duration(milliseconds: Engine.tickMs) (R7 — one constant, one owner), then add the test below.
  • How to prove the fix: 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-F2 — Every assertion about the alarm lead is computed from the constant it claims to test

  • Severity: HIGH
  • Location: test/engine_test.dart:61, :145, :166, :172; test/robustness_test.dart:291
  • What is wrong: Engine.alarmLeadMs = 1200 is the single number deciding how early a dish is called. Five assertions name it, and every one derives its expected instant from it, so the assertion holds for any value. Setting it to 400 ms (M03) leaves all 123 green.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the lead is a field decision backed by an 11-ring measurement (engine.dart:34-49): the spoken name must land ~300 ms before the deadline. If it silently became 400 ms the voice would land after the dish is due — the exact failure the constant exists to prevent — and the suite would sign it off.
  • Proposed fix: pin the value once, and add one literal-instant test; keep the derived assertions as readability aids.
  • How to prove the fix: 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

  • Severity: HIGH
  • Location: lib/ui/home.dart:248; file coverage 0.00 % (baseline §6)
  • What is wrong: home.dart is the EngineHost implementation — ringtone, spoken announcement, haptics, backstop sync, alarm-volume choke point, operator banner, double-tap window. No test imports it. Beyond the absence, it is not testable for time-dependent behaviour: the clock is hard-wired to the wall clock, so a testWidgets fake clock cannot drive a countdown.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: every side effect a cook perceives when a timer lands lives here. A dropped sounds.ringtone, an inverted _announceIfStill guard, a missing backstop.showNow while backgrounded — each ships with a green suite.
  • Proposed fix: 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 the fix: 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

  • Severity: HIGH
  • Location: lib/alarm_backstop.dart:181; test test/backstop_test.dart:88-96
  • What is wrong: test G1 is named "a PAST deadline is never scheduled (−10s past zero case)". It asserts that no zonedSchedule call reached the channel and that no critical banner appeared. Both stay true when the guard is removed, because flutter_local_notifications rejects a past date on the Dart side before the channel, and the resulting ArgumentError is caught at alarm_backstop.dart:204-207 into a non-critical Diag.fail('backstop-past', e). Widening the guard by ten minutes (M44) leaves all 123 green.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the guard exists because the 23/07 bug turned a −10 s adjustment into an ArgumentError cascade that latched a permanent "exact alarms denied" banner. The test meant to lock that fix in place is passing on the plugin's behaviour, not the app's.
  • Proposed fix: assert the guard directly instead of its downstream effect.
  • How to prove the fix: 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

  • Severity: HIGH
  • Location: lib/alarm_backstop.dart:108-112; test/backstop_test.dart:70-79
  • What is wrong: every backstop test builds the same single-step timer. The rule that a chained dish is backed by the end of the whole chain has no test; disabling the summation loop (M46) leaves all 123 green.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the pilot kitchen's chained dish is Cook chicken (360 + 90 + 360 s, store.dart:338-342). With the loop broken the OS net is armed for the first step boundary: if the app dies during a 13-minute chicken the tablet rings six minutes early, the cook clears it, and the real end of cook has no net at all.
  • Proposed fix: add the chained case to backstop_test.dart.
  • How to prove the fix: 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

  • Severity: HIGH
  • Location: lib/alarm_backstop.dart:127-129
  • What is wrong: sync()'s first job is the immediate cancel, and its own comment calls a phantom ring "the one thing we never risk". Replacing the condition with if (false) (M49) leaves all 123 green: no test ever stops a running timer and re-syncs.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the dish went out ten minutes ago and the tablet fires a full-screen alarm on the alarm stream for a timer that no longer exists. That is worse than a missed ring — it teaches the room to ignore the board.
  • Proposed fix: add the cancel path to backstop_test.dart.
  • How to prove the fix: 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-F7 — Notification ids have no test: every timer may collapse onto one id

  • Severity: MEDIUM
  • Location: lib/alarm_backstop.dart:67
  • What is wrong: _nid is the only thing keeping two dishes' OS alarms apart. Replacing it with a constant (M47) leaves all 123 green.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: with a shared id, arming the fries' backstop overwrites the chicken's, and cancelling the fries cancels the chicken's too — two dishes, one net.
  • Proposed fix / How to prove: 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-F8 — The voice queue's pacing, staleness and cancellation guards all survive mutation

  • Severity: HIGH
  • Location: lib/audio/voice.dart:177 (300 ms gap), :184 (20 s staleness), :174 (generation guard)
  • What is wrong: voice_test.dart covers which voice is picked and whether a phrase is dropped on a dead engine, but none of the three timing rules the queue is built around. Collapsing the beat to 0 ms (M38), never dropping a stale phrase (M39), and removing the guard that stops a cancelled utterance from re-draining (M40) each leave all 123 green — the tests wait 100–400 ms of real time, which absorbs the difference.
  • Evidence: 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.txteach end+123: All tests passed!,EXIT_CODE=0; three distinct patches. - **Why it matters for a restaurant kitchen:** without the beat, two dishes ringing together are announced back-to-back and neither name is intelligible over an extraction hood. Without the staleness rule, an announcement queued behind a wedged engine is spoken minutes later and names a dish already plated — the "no stale speech" rule the module's own header claims to hold. - **Proposed fix:** drive the queue withpackage:fake_asyncso the timings are asserted, not waited out. - **How to prove the fix:** three tests invoice_test.darttest('a phrase older than staleMs is dropped, not spoken'): enqueue,elapse(20001 ms), complete the in-flight utterance, assertspokennever contains it;test('the next utterance waits 300 ms'): complete the first,elapse(299 ms)spokenhas length 1,elapse(2 ms)→ length 2;test('a cancelled utterance never re-drains'):stopFormid-flight, assert exactly one re-drain. Red underM39.patch,M38.patch,M40.patch` respectively; green at HEAD.

S7-F9 — Nine of the ten fromJson fallback defaults are unprotected

  • Severity: MEDIUM
  • Location: lib/engine/models.dart:22 (×2), :67, :68, :72, :131-132, :134, :139, :158
  • What is wrong: models.dart is the boundary where a tablet's persisted state comes back after a kill. Ten ?? fallbacks decide what a missing field becomes; nine can be changed to any value with the suite still green (M19–M22, M24–M28). Only sound (M23) is caught, and only incidentally, by a zone-migration test that happens to assert kDefaultSound.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: these are the values a tablet wakes with after the manufacturer skin kills the app mid-service. voiceGap: 0 makes a restored ringing timer repeat its announcement on every 150 ms tick; stepIndex: 1 restores a chained dish one phase ahead of where it is; sec: 0 on a restored step makes it fire instantly and forever.
  • Proposed fix: one table-driven test asserting every documented default.
  • How to prove the fix: 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.

S7-F10 — TimerDef.isChain's >= 2 boundary has no test

  • Severity: MEDIUM
  • Location: lib/engine/models.dart:52
  • What is wrong: the definition of "chained" is the branch point for startTimer, tick, reconcile and the backstop's final-deadline rule. Lowering it to >= 1 (M18) leaves all 123 green, because every test reaches a one-step timer only through saveDef, which nulls the steps first (engine.dart:370) and so never exercises the getter at its boundary.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: a one-step "chain" restored from storage would be treated as chained, and tick()'s advance loop (engine.dart:317) would never enter — the dish would sit at its boundary and never ring.
  • Proposed fix / How to prove: 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

  • Severity: MEDIUM
  • Location: test/engine_test.dart:288-295
  • What is wrong: the test is titled "a batch clone rings like the dish it was cloned from". It spawns a clone, discards the returned id, and asserts e.soundFor(t) — on the parent TimerDef it configured two lines earlier. Since soundFor is t.sound (engine.dart:66), the assertion is already true before spawnClone is ever called.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: a second pan of fries ringing with the wrong tone, or with none, is exactly the regression this test is named for, and it would ship.
  • Proposed fix / How to prove: 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

  • Severity: MEDIUM
  • Location: test/grid_layout_test.dart:14, :15, :21, :23, :38, :72
  • What is wrong: six assertions compare a computed value against the constant that produced it. The aspect-cap test is the clearest: it iterates [1, 2, 4, 6, 12] timers and asserts rowH <= tileW * GridLayout.maxAspect + 1 — a bound recomputed from the mutated constant. Raising the cap to 0.95 (M50) genuinely changes the geometry at 2 timers and the suite stays green.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the aspect cap is what stops two timers becoming two letterbox slabs on a 16:10 tablet — a layout signed off by eye and pinned by nothing else.
  • Proposed fix: pin the constants and the 2-timer reference geometry with literals; keep the ratio tests as invariants.
  • How to prove the fix: 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-F13 — The ringtone-asset test cannot detect a filename-case mismatch on macOS

  • Severity: HIGH
  • Location: test/i18n_defaults_test.dart:70-78; subject lib/audio/audio.dart:83-84
  • What is wrong: the test guarding against "a SILENT alarm, the single worst failure this app has" resolves the asset through dart:io on the host filesystem. This machine's root volume is APFS and case-insensitive, so File('assets/audio/Chirp.wav').existsSync() returns true while the file on disk is chirp.wav. Removing toLowerCase() from assetFor (M52) therefore leaves all 123 green, while the app resolves assets by exact bundle key.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the tone is the alarm. A case drift in assetFor, or a WAV renamed with a capital, produces a timer that reaches zero and makes no sound — and this is the one test written specifically to stop that.
  • Proposed fix: assert the exact expected filename per tone, and compare against a real directory listing, which is case-exact on any filesystem.
  • How to prove the fix: 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-F14 — I18n.ttsLocale has no test

  • Severity: MEDIUM
  • Location: lib/i18n.dart:166
  • What is wrong: the mapping from app language to TTS locale is the only thing making a French kitchen hear French. Forcing it to 'en-US' (M53) leaves all 123 green — including voice_test.dart, which passes a locale in by hand rather than obtaining it from I18n.
  • Evidence: dart // lib/i18n.dart:166 String get ttsLocale => lang == 'fr' ? 'fr-FR' : 'en-US'; mutations/M53.txt → all 123 green, EXIT_CODE=0.
  • Why it matters for a restaurant kitchen: a French announcement read by an American English voice is the v0.4.7 defect the announcement suite exists to prevent, arriving through a different door.
  • Proposed fix / How to prove: 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

  • Severity: MEDIUM
  • Location: lib/engine/store.dart:158-161
  • What is wrong: robustness_test.dart:83 stores a double under cadence-lang and asserts the fallback — but that path is served by _readString's type-error catch, not by the fr/en validation. Replacing the validation with v ?? 'en' (M32) leaves all 123 green; no test ever stores a well-typed but unsupported code such as 'de'.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: I18n('de') falls back to English strings (i18n.dart:143) while ttsLocale would return 'en-US' for a UI that already looked English — a drift that only shows on a tablet whose stored value is corrupt, which is the case the validation is for.
  • Proposed fix / How to prove: 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-F16 — Six lib/ files, 837 of 1,927 instrumented lines, are executed by zero tests

  • Severity: HIGH (aggregate)
  • Location: lib/main.dart, lib/ui/home.dart, lib/ui/tile.dart, lib/ui/header.dart, lib/ui/theme.dart, lib/ui/logo.dart
  • What is wrong: no mutation in any of these files can ever be killed, because no line of them runs. tile.dart alone is 819 lines and is the only thing a cook looks at during service. Two spot mutations confirm it: the real heartbeat (M02, home.dart) and the negative clamp in fmtTime (M57, theme.dart) both survive.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the tile is the product. Its remaining-time digits, its count-up after a ring, its paused state, its phase banner and its batch chip are what the cook reads across the pass, and none of it is asserted anywhere.
  • Proposed fix: the test files specified in §4.
  • How to prove the fix: flutter test --coverage reports non-zero line coverage for each of the six, and M02.patch and M57.patch both go red.

S7-F17 — Three tests silently depend on the process working directory

  • Severity: LOW
  • Location: test/source_hygiene_test.dart:11, test/version_test.dart:16, test/i18n_defaults_test.dart:74
  • What is wrong: each reads a relative path (Directory('lib'), File('pubspec.yaml'), File('assets/audio/…')). They pass only because flutter test sets the working directory to the package root; invoked otherwise they throw rather than fail with a message.
  • Evidence: 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'))) {
  • Why it matters for a restaurant kitchen: indirect — it makes these three checks unusable from a release gate that invokes the test binary directly.
  • Proposed fix: 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 the fix: 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

  • Severity: LOW
  • Location: test/announcement_test.dart, test/editor_layout_test.dart, test/i18n_defaults_test.dart, test/volume_test.dart, test/engine_test.dart
  • What is wrong: Diag and Journal are process-global statics (diagnostics.dart:19-26, journal.dart:28-33). Five of thirteen files call Diag.reset(); two call Journal.disableForTests(). The rest inherit whatever earlier tests in the same file left behind. No failure results today — proven below — but the first test that asserts on Diag.critical inside announcement_test or volume_test would inherit a dirty set.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: indirect — it is the condition under which a future banner test passes or fails depending on what ran before it.
  • Proposed fix: one shared test/_setup.dart exporting void resetGlobals() calling Diag.reset() and Journal.disableForTests(), invoked from setUp in all 13 files.
  • How to prove the fix: 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.

S7-F19 — One test's pass depends on a 50 ms wall-clock margin

  • Severity: MEDIUM
  • Location: test/backstop_test.dart:169; supporting: test/voice_test.dart:59, :77, :94
  • What is wrong: the debounce test waits 350 ms of real time for a 300 ms debounce — a 16 % margin on a machine under arbitrary load. voice_test similarly waits 100 ms and 400 ms for 60 ms and 300 ms timers. Both pace themselves with Future.delayed rather than a controlled clock. Measured result: the suite is flake-free — 8 runs (5 identical + 3 randomized), 123 passing in every one.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: indirect. A suite that reddens at random teaches the team to re-run rather than read, and the first real regression is dismissed as "the flaky one".
  • Proposed fix: convert both suites to package:fake_async; the 350 ms wait becomes async.elapse(const Duration(milliseconds: 301)).
  • How to prove the fix: 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.

3. Checked and clean — no finding

Checked Result Basis
Tests that assert nothing None. All 123 contain at least one expect(; the minimum is 1, and every such case is either a single-value rule or an expect inside a loop. script over all 13 files, §5
Tests that only assert "did not throw" None. Four carry a // must not throw comment (store_test.dart:97, robustness_test.dart:89, :160, voice_test.dart:185); each also asserts a concrete outcome on the following line. verbatim read of all four
Assertion-free testWidgets (the case that defeats the weak R8 standard) None. All 8 testWidgets declarations contain at least one expect(. script output, §5
skip: markers 0 in test/ baseline SUMMARY.md
test/editor_layout_test.dart:37 registering twice Intentional and correct. The loop at :36 produces two distinctly named tests, both executed and both visible in every recorded run. It hides no missing case: the two languages are the only two the app supports (i18n.dart:8-37). flake_run_1.txt contains les durees rapides tiennent sur UNE ligne (fr), … (en) and chaque duree rapide est distincte et croissante — 3 tests from 2 declarations
Order dependence None observed. 3 randomized-order runs, 3 distinct seeds, 123 green each. random_order_1..3.txt
Flakiness None. 8 runs, identical result. flake_run_*.txt, random_order_*.txt
Over-mocking that removes the code under test None found. Every mock sits at a platform boundary the test cannot cross: dexterous.com/flutter/local_notifications (backstop_test.dart:33), cadence/tts (voice_test.dart:12), SharedPreferences.setMockInitialValues, PathProviderPlatform (journal_test.dart:31). The classes under test are real instances throughout. The one gap is the opposite of over-mocking: no test ever constructs a SoundBox (§4.7). read of all 13 files
Assertions on implementation rather than behaviour One structural duplication, not a defect. editor_layout_test.dart:18 re-implements the chip-label expression from modals.dart:302-304; because the test then searches for that text on screen, a production format change makes it fail rather than falsely pass. The tautologies that do matter are S7-F2 and S7-F12. verbatim comparison
Engine state machine Strong. 14 mutations (M04–M17) killed, of which 10 reddened exactly one test each — the highest specificity in the suite. §1b
AlarmVolume Strong. 3 of 3 killed (M35–M37); M33 and M37 each single-test. §1b

4. Missing-test specification for Phase 4

Ten lib/ files have no dedicated test file (code map §4.1). Each entry names the file to create, the test names, and the assertion each makes.

4.1 test/models_test.dart — for lib/engine/models.dart (160 lines, 72.13 %)

Test name Assertion
every fromJson fallback default is the documented one the ten-line block in S7-F9 — kills M19–M22, M24–M28
one step is not a chain, two steps are isChain false at 1, true at 2 — kills M18
legacyZoneId is read from storage but never written back TimerDef.fromJson({'id':'a','zoneId':'four'}).legacyZoneId == 'four' and t.toJson().containsKey('zoneId') == false
RunEntry.toJson omits the chain fields for a single timer RunEntry(status: running).toJson().keys excludes chain and stepIndex; a chained entry includes both
a TimerDef survives a full toJson/fromJson round trip including steps field-by-field equality on a 3-step definition

4.2 test/home_test.dart — for lib/ui/home.dart (722 lines, 0.00 %)

Requires the injectable clock of S7-F3. | Test name | Assertion | |---|---| | a restored overdue timer rings on the first tick and announces exactly once | recording voice double: enqueue called once, with I18n('fr').announcementFor(name, '') | | an announcement is dropped when the cook stops the timer inside the 900 ms window | stop at +400 ms → enqueue never called (guards home.dart:275-282) | | the critical banner names the down capability | Diag.fail('voice-init','x',isCritical:true)find.text(I18n('fr').call('voiceDown')) findsOneWidget; after Diag.clearCritical → findsNothing | | two taps inside 260 ms reset the timer; one tap outside it pauses | exercises Engine.dblMs (home.dart:387): engine.run[id] null vs RunStatus.paused | | going to background while a timer rings posts the notification immediately | mocked notifications channel receives show (covers home.dart:284-305 + backstop.showNow) |

4.3 test/tile_test.dart — for lib/ui/tile.dart (819 lines, 0.00 %)

Test name Assertion
a running tile prints the remaining time as m:ss and counts down endsAt = nowMs + 65000find.text('1:05'); re-pump at nowMs + 5000find.text('1:00')
a ringing tile counts UP from rangAt with a + prefix rangAt = nowMs - 12000find.text('+0:12')
a paused tile freezes its digits two pumps 10 s apart with status: paused yield identical text
a chained tile names the live phase and its index find.text('Flip') and find.textContaining('2/3')
the duplicate chip prints the batch number it would create dupLabel: '#3'find.text('#3') findsOneWidget
all six animation controllers are disposed with the tile pump the tile, replace with SizedBox, pump → expect(tester.takeException(), isNull)

4.4 test/header_test.dart — for lib/ui/header.dart (215 lines, 0.00 %)

Test name Assertion
the clock colon blinks below 500 ms and is hidden above build with now: DateTime(2026,1,1,12,34,0,100) then …,700); the rendered clock differs, and only in the separator (guards header.dart:143)
at 460 logical px the header degrades to icon-only without overflowing surface 460×800 → expect(tester.takeException(), isNull) and the new label findsNothing (guards the ladder at header.dart:33-37)
each header button fires its own callback three recording callbacks, three taps, three distinct hits

4.5 test/theme_test.dart — for lib/ui/theme.dart (82 lines, 0.00 %)

Test name Assertion
fmtTime formats m:ss and clamps a negative to 0:00 fmtTime(-5) == '0:00', fmtTime(0) == '0:00', fmtTime(65) == '1:05', fmtTime(59.6) == '1:00' — kills M57
fmtUp prefixes the count-up with + fmtUp(12) == '+0:12'
fillFor is continuous at its two junctions fillFor(0.35) equals the amber anchor exactly and fillFor(0.15) the red anchor; fillFor(1.0)/fillFor(0.0) equal the mint/red endpoints
no two tones and no two presets are duplicated C.tones.toSet().length == C.tones.length (12); C.presets strictly increasing in seconds

4.6 test/logo_test.dart — for lib/ui/logo.dart (18 lines, 0.00 %)

Test name Assertion
CadenceMark renders the bundled mark at the requested height tester.widget<Image>(…).height == 40 and the AssetImage.assetName == 'assets/logo/mark_white.png' — catches the asset being dropped from pubspec.yaml

4.7 test/audio_test.dart — for lib/audio/audio.dart (116 lines, 5.26 %)

Test name Assertion
assetFor produces the exact on-disk filename the block in S7-F13 — kills M52
playing before init() reports through Diag instead of throwing construct SoundBox(), await ringtone('Chirp'), expect(Diag.log.map((d)=>d.scope), contains('audio-play')) and expect(Diag.critical.value, contains('audio-play')) (guards audio.dart:64-68)
a failing vibration probe does not stop init from building the player pool mock the vibration channel to throw; after init(), ringtone('Chirp') records no audio-play failure

4.8 test/main_test.dart — for lib/main.dart (58 lines, 0.00 %)

Test name Assertion
CadenceApp disables OS text scaling pump CadenceApp(store: …), read MediaQuery.of(context).textScaler beneath HomeScreen, assert TextScaler.noScaling — guards main.dart:48, the only thing stopping a 200 %-font tablet from bursting the tile digits
(kAppVersion vs pubspec) already covered by version_test.dart:15 — keep, do not duplicate

4.9 test/diagnostics_test.dart — for lib/diagnostics.dart (54 lines, 86.36 %)

Test name Assertion
the failure log is capped at 50 and keeps the most recent 60 Diag.fail calls → Diag.log hasLength 50 and Diag.log.last.message contains '59' (guards _max, diagnostics.dart:21)
clearCritical removes only the named scope two critical scopes, clear one, the other remains

4.10 test/modals_test.dart — for lib/ui/modals.dart (746 lines, 65.33 %)

Test name Assertion
the editor floors a step at 5 s and clamps minutes to 0..180 _commitStep/bump duplicate Engine.saveDef (modals.dart:518-524 vs engine.dart:368); assert both ends through the widget
the ringtone picker previews the tone it selects recording previewSound, tap a tone, assert the exact tone name arrives
switching single → chain → single keeps the typed name and announcement enter both fields, toggle mode twice, assert both survive

Tests that belong in existing files, each already specified above with its killing patch: S7-F1 (heartbeat), S7-F2 (alarm lead), S7-F4 (backstop guard), S7-F5 (chained backstop), S7-F6 (backstop cancel), S7-F7 (notification ids), S7-F8 (voice timing ×3), S7-F12 (grid constants), S7-F14 (ttsLocale), S7-F15 (Store.lang), S7-F11 (clone tone).


5. Coverage manifest — test/, 13 files, 2,313 lines (complete)

Every file was read end to end at 03a176e.

# File Lines What I checked in it
1 test/announcement_test.dart 267 All 13 tests read. Verified the four widget tests drive the real showTimerEditor and assert rendered text, not internals. No Diag/Journal reset (S7-F18). Confirmed the operator-text-verbatim and empty-field-generates rules are asserted with literals rather than derived values. Mutations landing here: M23, M31, M55.
2 test/backstop_test.dart 191 All 6 tests read. Verified the mock sits at the plugin channel, not around Backstop. Established that engineWith builds only single-step timers (S7-F5), that no test removes a timer to check the cancel path (S7-F6), that G1 passes on plugin behaviour rather than the app guard (S7-F4), and that notification ids are never asserted (S7-F7). Timing margin measured (S7-F19). Mutations: M43–M49, M55.
3 test/editor_layout_test.dart 79 Both declarations read. Confirmed the for (final lang …) loop at :36 registers two distinctly named tests, both executed (§3). Compared presetLabel against modals.dart:302-304 and established it fails closed. C.presets is read live, not copied.
4 test/engine_test.dart 341 All 21 tests read. Source of 10 single-test validations (§1b). Found the alarm-lead tautology at :61, :145, :166, :172 (S7-F2) and the clone test asserting on the parent at :288 (S7-F11). Verified FakeHost records rather than stubs, and that the clock is injected.
5 test/grid_layout_test.dart 152 All 13 tests read. Found six tautological ratio/aspect assertions (S7-F12) and confirmed by independent recomputation that the 2-timer geometry genuinely changes under M50 while the suite stays green. Confirmed the 602×332 reference test and the column table are genuine goldens — M51 died on the latter.
6 test/i18n_defaults_test.dart 117 All 6 tests read. Found the asset test's case-insensitivity blind spot (S7-F13) and the absence of any ttsLocale assertion (S7-F14). Verified the tone list is read from C.tones rather than copied. Working-directory coupling noted (S7-F17).
7 test/journal_test.dart 193 All 11 tests read. Verified they use real files in a real temp directory with PathProviderPlatform overridden, and that tearDown cancels the two static timers. Kill detection and the beat-dedup stamp are genuinely asserted — M54 died alone on :148. Real delays at :115 (50 ms) and :160 (20 ms) reviewed against the 8-run flake evidence.
8 test/robustness_test.dart 314 All 16 tests read. Source of 5 single-test validations. Found the second alarm-lead tautology at :291. Verified the batch-label regression group asserts stable numbers with literals.
9 test/source_hygiene_test.dart 25 The single test read. Confirmed it asserts a non-empty offender list with a reason, and depends on the working directory (S7-F17). Exercises no lib/ symbol by design.
10 test/store_test.dart 220 All 10 tests read. Source of 4 single-test validations (M23/M29, M30, M31). Verified the corrupt-preservation assertions use literal stored strings. No Journal.disableForTests() (S7-F18) — harmless today because Journal._file is null.
11 test/version_test.dart 29 The single test read. Genuine: it parses pubspec.yaml independently of kAppVersion. Working-directory coupling noted (S7-F17).
12 test/voice_test.dart 197 All 9 tests read. M41 died alone on :118; M42 on :156 and :170. Established that the 300 ms gap, the 20 s staleness rule and the generation guard all survive (S7-F8). Verified the Completer mock genuinely holds an utterance open rather than stubbing the queue. Real delays at :59, :77, :94 reviewed.
13 test/volume_test.dart 188 All 13 tests read. The strongest file in the suite: 3 of 3 AlarmVolume mutations plus Store.vol (M33) killed, with M33 and M37 each reddening exactly one test. The widget test's find.text('15 %') is what catches a floor change (M35, confirmed as a TestFailure at volume_test.dart:165). Confirmed make() records every pushed level in order rather than asserting on internals.
Total 2,313

Supporting files under proof/01_findings/S7/: baseline_copy_test.txt, flake_run_1..5.txt, random_order_1..3.txt, revert_clean_check.txt, subject_repo_untouched.txt, mutations_driver.py, flake_driver.sh, render_table.py, and mutations/ containing M01..M57.patch, M01..M57.txt, results.json and TABLE.md.

S7 REFUTE — adversarial verification of the test-suite / mutation-testing streamagent_reports/S7_refute.md · raw .md

S7 REFUTE — adversarial verification of the test-suite / mutation-testing stream

Refuter for stream S7. Governing rule: R5. Standard applied to S7's own method: R8 (a)–(d), R12. Subject read-only at 03a176e72ef0075eec86b8915cbe6e93042a3b9d; all experiments on my own copy a scratch working copy, cloned from the subject and checked out at the pinned sha. Subject repo untouched: proof/01_findings/S7_refute/subject_repo_untouched.txt (git status --porcelain empty, EXIT_CODE=0).

My raw proof: proof/01_findings/S7_refute/ — 57 json-reporter re-runs (reruns/M01..M57.json.txt), 13 new mutation runs (extension/N01..N13.json.txt + patches), 5 flake runs (flake/), a coverage run, line_execution.json, rerun_comparison.txt, mutation_score_rederivation.txt, and the six drivers that produced them.


0. Verdict summary

S7 claim My verdict
32 killed / 57 = 56.1 %, 25 SURVIVED Reproduced exactly. 57 of 57 patches agree, 0 disagreements.
22 of 32 kills meet the strengthened R8 standard Reproduced. 22 single-test kills; all 22 are assertion failures, not throws.
19 distinct tests individually validated of 123 Arithmetic correct, standard applied consistently, claim STANDS. Wording in §1b is precise; the §0 headline row is not, and needs one qualifier.
All 57 runs show passed + failed = 123, no compile-breaking mutation Confirmed independently, and more strongly: my json capture shows 123 testStart events and 0 load-time errors in every one of the 57.
Suite is order-independent and flake-free Held. 5 further runs of mine (2 identical, 2 fresh random seeds, 1 fixed seed) — 123 pass, exit 0, every time. 13 independent runs now agree.
25 survivors 24. One (M01) is an equivalent mutant and is a false survivor.
56.1 % mutation score Not a fair characterisation of the suite. It is an OVERSTATEMENT. Line-proportional re-derivation gives 29.5 %; pooling my 13 extra mutations with S7's 57 gives 33/70 = 47.1 %.
Per-run proof integrity (R8(d), R12) Fails. Not one of S7's 69 recorded files carries a TREE_STATE: line, and all 57 mutation runs used --reporter expanded, not the --reporter=json R8(a) requires. The results are nonetheless correct — I re-derived them with the required capture.

Findings contributed: 6 (S7R-F1 … S7R-F6), all at R2 standard.


1. Re-run of all 57 saved patches (brief asked for ≥12; I did all 57)

Method, deliberately different from S7's so this is an independent check, not a repeat:

  • I applied the saved patch artifact with git apply --verbose <M**nn**.patch>, not S7's string replacement — this verifies the artifact itself.
  • I captured every run with flutter test --reporter json, which is what R8(a) actually demands. The json stream carries a testDone.result of "failure" (an expect went red) versus "error" (the test threw, or the file failed to load), plus the exact failing set — none of which the expanded reporter gives without inference.
  • I recorded git status --porcelain before apply, after apply, and after revert for every single mutation.
  • Every run went through proof/run_and_record.sh [not published] with CADENCE_REPO pinned to my copy, so each capture carries a TREE_STATE: stamp.

Machine-checked integrity of my own campaign (rerun_results.json):

runs: 57
pre-apply tree clean in all: True
exactly 1 file dirty after apply in all: True
post-revert tree clean in all: True
apply_rc==0 in all: True
n_tests_seen==123 in all: True
load failures total: 0

mode below: A = assertion (matcher / TestFailure), T = thrown exception. res: f = json result: "failure", e = json result: "error". line = whether the mutated production line is executed by the suite at all (line_execution.json, from my own flutter test --coverage on the copy; NOT-INSTRUMENTED = a static const declaration, which lcov does not instrument — those are judged by consumer).

# S7 verdict My verdict n red S7 / mine mode/res mutated line agree?
M01 SURVIVED SURVIVED 0 / 0 -/- NOT-INSTRUMENTED AGREE
M02 SURVIVED SURVIVED 0 / 0 -/- NEVER-EXECUTED AGREE
M03 SURVIVED SURVIVED 0 / 0 -/- NOT-INSTRUMENTED AGREE
M04 KILLED KILLED 1 / 1 A/f NOT-INSTRUMENTED AGREE
M05 KILLED KILLED 1 / 1 A/f NOT-INSTRUMENTED AGREE
M06 KILLED KILLED 1 / 1 A/f NOT-INSTRUMENTED AGREE
M07 KILLED KILLED 1 / 1 A/f NOT-INSTRUMENTED AGREE
M08 KILLED KILLED 3 / 3 A/f EXECUTED (5) AGREE
M09 KILLED KILLED 4 / 4 A/f EXECUTED (8) AGREE
M10 KILLED KILLED 3 / 3 A/f EXECUTED (10) AGREE
M11 KILLED KILLED 3 / 3 A/f EXECUTED (2) AGREE
M12 KILLED KILLED 1 / 1 A/f EXECUTED (5) AGREE
M13 KILLED KILLED 1 / 1 A/f EXECUTED (4) AGREE
M14 KILLED KILLED 2 / 2 A/f EXECUTED (4) AGREE
M15 KILLED KILLED 1 / 1 A/f EXECUTED (3) AGREE
M16 KILLED KILLED 1 / 1 A/f EXECUTED (6) AGREE
M17 KILLED KILLED 1 / 1 A/f EXECUTED (3) AGREE
M18 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (15) AGREE
M19 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (3) AGREE
M20 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (3) AGREE
M21 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (2) AGREE
M22 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (2) AGREE
M23 KILLED KILLED 1 / 1 A/f EXECUTED (2) AGREE
M24 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (2) AGREE
M25 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (1) AGREE
M26 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (1) AGREE
M27 SURVIVED SURVIVED 0 / 0 -/- NEVER-EXECUTED AGREE
M28 SURVIVED SURVIVED 0 / 0 -/- NEVER-EXECUTED AGREE
M29 KILLED KILLED 1 / 1 A/f NOT-INSTRUMENTED AGREE
M30 KILLED KILLED 1 / 1 A/f NOT-INSTRUMENTED AGREE
M31 KILLED KILLED 1 / 1 A/f EXECUTED (11) AGREE
M32 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (5) AGREE
M33 KILLED KILLED 1 / 1 A/f EXECUTED (6) AGREE
M34 KILLED KILLED 1 / 1 A/f EXECUTED (4) AGREE
M35 KILLED KILLED 2 / 2 A+T / f+e NOT-INSTRUMENTED AGREE (see S7R-F2)
M36 KILLED KILLED 4 / 4 A/f EXECUTED (6) AGREE
M37 KILLED KILLED 1 / 1 A/f EXECUTED (1) AGREE
M38 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (2) AGREE
M39 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (3) AGREE
M40 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (2) AGREE
M41 KILLED KILLED 1 / 1 A/f EXECUTED (3) AGREE
M42 KILLED KILLED 2 / 2 A/f EXECUTED (1) AGREE
M43 KILLED KILLED 1 / 1 A/f NOT-INSTRUMENTED AGREE
M44 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (4) AGREE
M45 KILLED KILLED 1 / 1 A/f NOT-INSTRUMENTED AGREE
M46 SURVIVED SURVIVED 0 / 0 -/- NEVER-EXECUTED AGREE
M47 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (3) AGREE
M48 KILLED KILLED 1 / 1 A/f EXECUTED (1) AGREE
M49 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (1) AGREE
M50 SURVIVED SURVIVED 0 / 0 -/- NOT-INSTRUMENTED AGREE
M51 KILLED KILLED 1 / 1 A/f NOT-INSTRUMENTED AGREE
M52 SURVIVED SURVIVED 0 / 0 -/- EXECUTED (3) AGREE
M53 SURVIVED SURVIVED 0 / 0 -/- NEVER-EXECUTED AGREE
M54 KILLED KILLED 1 / 1 A/f EXECUTED (1) AGREE
M55 KILLED KILLED 7 / 7 A/f EXECUTED (25) AGREE
M56 KILLED KILLED 8 / 8 A+T / f+e EXECUTED (12) AGREE
M57 SURVIVED SURVIVED 0 / 0 -/- NEVER-EXECUTED AGREE

57 AGREE, 0 DISAGREE. Verbatim, rerun_comparison.txt:

TOTAL disagreements: 0 of 57

Every patch applied cleanly to 03a176e (apply_rc == 0, exactly one path dirty afterwards) and reverted cleanly (git status --porcelain empty after every one). The failing-set contents agree name-for-name, not merely in count — the comparison script matches normalised test-name sets, not cardinalities, and reported no mismatch.

I also re-verified S7's two supporting integrity claims directly against its own artifacts: SHA-256 over each patch body excluding the provenance line gives 57 distinct hashes of 57, and the final expanded-reporter counters give passed + failed = 123 in all 57, with Failed to load appearing in none.


2. Are the 25 SURVIVED mutations genuinely semantic? — 24 real, 1 false

I judged each survivor on two axes: does it change behaviour at all (equivalence), and does the suite even execute the mutated line (which decides what the survival proves).

2.1 The one false survivor — M01

Engine.tickMs has zero consumers anywhere in the repository. Recorded proof, proof/01_findings/S7_refute/tickMs_zero_consumers.txt, verbatim:

COMMAND:    sh -c 'grep -rn "tickMs" lib/ test/'
CWD:        the app repository
GIT_HEAD:   03a176e72ef0075eec86b8915cbe6e93042a3b9d
TREE_STATE: CLEAN
--------------------------------------------------------------------------------
lib/engine/engine.dart:32:  static const int tickMs = 150;
EXIT_CODE=0

One line: the declaration. Changing 150 to 200 in a constant that nothing reads cannot alter any observable behaviour, in the app or in a test. In mutation-testing terms this is an equivalent mutant, and equivalent mutants are excluded from the denominator by construction — they are not evidence that the suite is weak, only that the constant is dead. S7 knows the constant is dead (it is the whole of S7-F1) yet still counts M01 among the 57 and among the 25 survivors.

Correction on S7's own sample: 32 / 56 = 57.1 %, survivors 24, not 25. S7-F1 itself is unaffected — it rests on M02, the real heartbeat literal, which is a genuine survivor.

2.2 The 24 real survivors, split into two classes S7 merges

The remaining 24 all change real behaviour, but they do not all mean the same thing:

Class A — 6 survivors on lines the suite never executes (M02 home.dart:154, M27 models.dart:132, M28 models.dart:158, M46 alarm_backstop.dart:109, M53 i18n.dart:166, M57 theme.dart:78; all hits = 0 in line_execution.json). These survive by construction — no assertion anywhere can catch a change to a line that never runs. They measure a coverage hole, not a weak assertion. They are correctly reported as findings (S7-F1, F5, F14, F16 rest on them) but they carry no information about assertion quality, and pooling them into a single "mutation score" mixes two different measurements.

Class B — 18 survivors the suite genuinely executes and still does not catch. This is the real result and it is stronger than the headline suggests. Verbatim from line_execution.json, the execution counts of the survivors the suite runs: M18 (15 hits), M19 (3), M20 (3), M21 (2), M22 (2), M24 (2), M25 (1), M26 (1), M32 (5), M38 (2), M39 (3), M40 (2), M44 (4), M47 (3), M49 (1), M52 (3), plus M03 and M50, whose constants are static const (uninstrumented) but are read by executed code — S7 proved M50's effect by independent recomputation of GridLayout.solve, which I reproduced: at 1280×740 with 2 timers, rowH is 512 at maxAspect 0.85 and 572 at 0.95, and the test at grid_layout_test.dart:72 passes under both because its bound is recomputed from the mutated constant.

Every fallback survivor is reachable on real persisted data, so none is equivalent. Verbatim lib/engine/models.dart:118-128 shows RunEntry.toJson writing chain/stepIndex only when chain is true, and lib/engine/models.dart:153-154 shows CloneRef.toJson omitting batchNo when it is 0 — so M26 (stepIndex fallback) and M28 (batchNo fallback) are exercised by ordinary stored state, not only by corruption.

Count requested: 1 of 25 survivors is false.


3. Attacking the 56.1 % headline — it is an OVERSTATEMENT

A mutation score is only as representative as its sample. I re-derived it against the baseline's own instrumented-line census (proof/00_baseline/SUMMARY.md §6, 1,927 lines across 18 files) and against 13 mutations of my own placed in the regions S7 sampled zero times.

Verbatim, proof/01_findings/S7_refute/mutation_score_rederivation.txt:

S7 headline                : 32 killed / 57 = 56.1%
pooled (S7 57 + refuter 13): 33 killed / 70 = 47.1%

file                            lines    cov%  S7 muts allmuts   killed  kill rate S7 vs line-prop
lib/main.dart                      17    0.00        0       1        0       0.0%      0.00x
lib/ui/header.dart                 70    0.00        0       2        0       0.0%      0.00x
lib/ui/home.dart                  390    0.00        1       1        0       0.0%      0.09x
lib/ui/logo.dart                    4    0.00        0       0        0       0.0%      0.00x
lib/ui/theme.dart                  12    0.00        1       1        0       0.0%      2.82x
lib/ui/tile.dart                  344    0.00        0       3        0       0.0%      0.00x
lib/audio/audio.dart               38    5.26        1       2        0       0.0%      0.89x
lib/ui/modals.dart                300   65.33        0       6        1      16.7%      0.00x
lib/engine/models.dart             61   72.13       11      11        1       9.1%      6.10x
lib/alarm_backstop.dart           100   78.00        7       7        3      42.9%      2.37x
lib/journal.dart                  104   81.73        1       1        1     100.0%      0.33x
lib/audio/voice.dart               88   86.36        5       5        2      40.0%      1.92x
lib/diagnostics.dart               22   86.36        1       1        1     100.0%      1.54x
lib/i18n.dart                      10   90.00        1       1        0       0.0%      3.38x
lib/engine/store.dart             125   94.40        6       6        5      83.3%      1.62x
lib/engine/engine.dart            199   94.47       17      17       15      88.2%      2.89x
lib/audio/alarm_volume.dart        14  100.00        3       3        3     100.0%      7.24x
lib/ui/grid_layout.dart            29  100.00        2       2        1      50.0%      2.33x

line-weighted mutation score = 29.5%
six 0%-coverage files: 837/1927 = 43.4% of instrumented lines; S7 sampled 2 of 57 there
(line-proportional = 24.8). Every mutation on a never-executed line survives by construction.

The sample is skewed in both directions, and the two skews do not cancel:

  • Flattering skew, and it dominates. engine.dart (94.47 % covered, the strongest test file in the suite) took 17 of 57 mutations — 2.89× its line-proportional share — and returned an 88.2 % kill rate. alarm_volume.dart took 3 at 7.24× its share and returned 100 %. Meanwhile the six files at 0.00 % coverage hold 43.4 % of all instrumented lines and took 2 of 57. Under a line-proportional sample they would take ~25, and every one of those 25 would survive.
  • Damning skew, and it is small. models.dart took 11 mutations at 6.10× its share and returned 9.1 %. But models.dart is 61 lines — 3.2 % of the codebase — so it cannot move the aggregate much.
  • Two large regions were never sampled at all. modals.dart (300 instrumented lines, 65.33 % covered, the second-largest instrumented file, exercised by both widget-test files) and tile.dart (344 lines) each received zero of S7's 57. I filled both (§5).

Weighting each file's measured kill rate by its instrumented lines gives 29.5 %. Pooling my 13 extra mutations with S7's 57 without any weighting gives 47.1 %.

Verdict: 56.1 % is accurate as "56.1 % of the 57 mutations S7 chose" and S7 does describe its method honestly in the body. But as the report's single headline figure for what the suite catches, it overstates by roughly a factor of two. A fair characterisation is ≈30 %, and the report should carry the line-weighted figure next to the raw one.


4. Attacking "19 distinct validated tests of 123" — the claim STANDS

Arithmetic. 22 mutations produce a single-test failing set. Three collapse onto one test (M04/M05/M06) and two onto another (M23/M29), so 22 − 2 − 1 = 19 distinct tests. Correct.

Is the standard applied consistently? Yes, and I re-derived it from the json stream rather than from S7's regex over the expanded reporter. Verbatim, 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

All 19 clear R8 fully: single-test failing set, result: "failure" (not "error"), cause is a matcher, patch distinct, tree clean before and after. The 19 names in my json output match S7's §1b table one for one. S7 also correctly excludes the ten multi-test kills from individual validation, and correctly excludes M56 entirely (my json confirms 7 of its 8 reds are Null check operator used on a null value with result: "error", and only cap at 3 per family is an assertion).

Does "validated" mean what a reader would take it to mean? In §1b, yes — S7's residual-honesty paragraph states it exactly: "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: I ran 57 mutations, not one per test." That is the precise statement, and it is the correct one.

The problem is the §0 headline table, which prints Distinct tests thereby individually validated | 19 of 123 with no qualifier. Lifted out of the report — which is exactly what a headline row is for — "19 of 123" reads as "only 19 of the 123 tests are real". That is not what was measured, and S7's own §3 evidence contradicts it: all 123 contain at least one expect(, none is assertion-free, and my json data adds a second bound S7 never computed — 43 distinct tests of 123 went red under at least one of the 57 mutations, i.e. 43 are demonstrably sensitive to a real production change, of which 19 are pinned to a specific behaviour alone. Fixed as S7R-F1 below.


5. Missed findings

S7R-F1 — The 19 of 123 headline row states as measured what §1b explicitly disclaims

  • Severity: MEDIUM
  • Location: findings/S7_tests.md §0 headline table, row "Distinct tests thereby individually validated"; contradicted by the same file's §1b residual-honesty paragraph and §3 row 1
  • What is wrong: the headline prints 19 of 123 unqualified. The measured fact is "19 tests are individually pinned by a single-test assertion kill"; the unmeasured implication a reader takes is "104 tests are unproven or worthless". S7's own §1b says the opposite, and a bound S7 never computed says it more strongly: 43 distinct tests of 123 went red under at least one mutation.
  • Evidence: 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."
  • Why it matters for a restaurant kitchen: this is the number that decides whether Serge's suite is treated as a real safety net or as decoration. Understating it invites someone to rewrite tests that already work, and every hour spent there is an hour not spent on tile.dart, which has none.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: all 57 of proof/01_findings/S7/mutations/M01..M57.txt
  • What is wrong: R8(a) requires the whole-suite run under each mutation to be captured with --reporter=json, and R8(b) requires the named test's result to be "failure" rather than "error". Those two fields exist only in the json stream. S7 ran the expanded reporter for all 57 and inferred the failing set with a regex over [E] lines, then classified assertion-versus-throw by eye from the printed stack. The conclusions happen to be right — I re-ran all 57 under json and agree 57/57 — but S7's own records cannot demonstrate R8(a) or R8(b), because the fields are not in them.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: indirect. It means the audit's central evidence base could not be re-graded against its own rule without re-running the whole campaign, which is what I had to do.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: every .txt under proof/01_findings/S7/; proof/01_findings/S7/revert_clean_check.txt
  • What is wrong: run_and_record.sh stamps TREE_STATE: precisely so that a run recorded against a tree still dirty from an unreverted mutation cannot be mistaken for a clean one — its own comment says the stamp exists because "a 'red' run under an unreverted mutation would look identical to a genuine failure". S7's 69 recorded files predate that stamp and carry none of it. R8(d)'s requirement that git status --porcelain be empty after revert is evidenced by one aggregate check taken at 10:08:15Z, after all 57 runs (10:02–10:08) had finished — it proves the tree was clean at the end, not that it was clean before each of the 57.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: indirect, and it is a proof-integrity defect rather than a wrong result. The hole is closed rather than merely flagged: my 57 re-runs each carry TREE_STATE, each records the working tree before apply, after apply and after revert, and all 57 reproduce S7's verdict — so no S7 result was in fact corrupted by a carried-over mutation.
  • Proposed fix: 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 the fix: 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-F4 — lib/ui/modals.dart (300 instrumented lines, 65 % covered) received zero mutations; five of six I placed there survive

  • Severity: HIGH
  • Location: lib/ui/modals.dart:217, :303, :428, :436, :523, :524 (valid at 03a176e)
  • What is wrong: modals.dart is the second-largest instrumented file and the only place a cook ever edits a timer, and it is reached by two widget-test files — yet S7 placed none of its 57 mutations in it, so nothing in the audit says whether its 65 % line coverage carries any assertions. I placed six. Five survive. The editor's own duration floors and clamps — which duplicate Engine.saveDef (R7 territory: modals.dart:520-525 versus engine.dart:368) — are entirely unprotected: removing the 5 s per-step floor, widening the seconds clamp from 0..59 to 0..120, raising the minute cap from 180 to 300, changing the ±5 s step to ±10 s, and changing the new-timer default from 180 s to 60 s all leave 123 green.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the editor is where a chef types a cook time under pressure. A broken seconds clamp lets 0:75 be entered and stored; a lost 5 s floor lets a 0-second step through the modal, and Engine.saveDef is the only thing that then catches it — a duplication R7 says should not exist and that nothing tests on the modal side.
  • Proposed fix: 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 the fix: 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 I placed survive

  • Severity: HIGH
  • Location: lib/ui/tile.dart:183, :200, :498 (valid at 03a176e)
  • What is wrong: S7-F16 asserts the six 0 %-coverage files are unguarded but spot-checks only home.dart and theme.dart. tile.dart — 344 instrumented lines, the single thing a cook looks at across the pass — was never probed. I probed the three text outputs that matter: the idle duration, the ringing count-up, and the chained step count. All three can be broken outright with the suite fully green.
  • Evidence: 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
  • Why it matters for a restaurant kitchen: N07 turns the count-up after a ring into a count-down with no + sign — a cook reading 0:12 on a ringing tile reads twelve seconds left, not twelve seconds overdue. N12 removes the only thing stopping a tablet set to 200 % system font from bursting the tile digits. Neither would redden anything.
  • Proposed fix: 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 the fix: the new tests go red under extension/N07.patch, N08.patch, N09.patch, N10.patch, N11.patch, N12.patch and green at HEAD.

S7R-F6 — Five of the thirteen test files were never reddened by any of the 57 mutations, and S7 does not report it

  • Severity: MEDIUM
  • Location: test/announcement_test.dart (267 lines, 13 tests), test/editor_layout_test.dart (79, 2 declarations → 3 tests), test/i18n_defaults_test.dart (117, 6), test/source_hygiene_test.dart (25, 1), test/version_test.dart (29, 1)
  • What is wrong: S7's per-file manifest states which mutations "land" in each file, but never reports the inverse and more useful measure: which files no mutation ever moved. Five of thirteen — carrying 24 of the 123 tests — never went red once across all 57 mutations. S7's manifest row for announcement_test.dart says "Mutations landing here: M23, M31, M55", which reads as coverage of that file; in fact M23 and M31 redden only store_test.dart, and announcement_test.dart was not reddened by anything.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: announcement_test.dart is the suite that exists because a French kitchen was being announced to in English. Nobody has shown it would notice if the behaviour regressed again.
  • Proposed fix: 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 the fix: 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.

6. Missing-test specifications (S7 §4) — implementable, and killable, with three exceptions

I checked each specification against the production code it targets, at 03a176e. This matters more now that S3 has added 40 tests and Phase 4 will adopt both sets: a specification that yields an unfalsifiable test is worse than none.

Spec Implementable as written? Would the test be killable? Notes
§4.1 models_test.dart — 5 tests Yes Yes — kills M18–M22, M24–M28 legacyZoneId exists (models.dart:39), is read at :78, and is absent from toJson (:56-63), so test 3 is exactly right. RunEntry.toJson writes chain/stepIndex only if (chain) (:118-128), so test 4 is exactly right.
§4.2 home_test.dart — 5 tests Yes, after the S7-F3 clock injection, which the spec names as a prerequisite Yes — the heartbeat test kills M02 Engine.dblMs = 260 exists (engine.dart:51, used at home.dart:387); _announceIfStill is home.dart:275-281; backstop.showNow is home.dart:300. All four cited anchors are real.
§4.3 tile_test.dart — 6 tests Yes, with no prerequisite Yes — kills my N07, N08, N09 TileView takes nowMs and dupLabel as constructor parameters (tile.dart:15, :21), so the time assertions need no clock injection. fmtTime(65.0) is '1:05' and fmtUp(12.0) is '+0:12', so the literals are right. Exactly six AnimationControllers exist (tile.dart:65-70), so "all six" is accurate.
§4.4 header_test.dart — 3 tests Yes Yes — kills my N10 and N11 Header takes now: DateTime (header.dart:13); the blink is header.dart:143 final colonOn = reduced || now.millisecond < 500;; the width ladder is header.dart:33-37. At 460 px, iconsOnly is true and showClock false — consistent with the spec.
§4.5 theme_test.dart — 4 tests Yes Yes — kills M57 I checked every literal. fmtTime(-5) is '0:00' at HEAD and '0:55' under M57 (Dart's % returns non-negative), so the test genuinely reddens. fillFor(0.35) returns the amber anchor exactly and fillFor(0.15) the red anchor, because both mix with t = 0 (theme.dart:66-67); fillFor(1.0) is 0xFF5CC79A = the mint anchor and fillFor(0.0) is 0xFFEC6A6A = the red anchor. C.tones has 12 entries; C.presets is strictly increasing at 30/60/180/300/600/900 s.
§4.6 logo_test.dart — 1 test Yes Partly — the assertion is falsifiable, but it does not deliver the guard the spec claims See S7R-F7 below.
§4.7 audio_test.dart — 3 tests Tests 1 and 2 yes; test 3 under-specified Yes for 1 and 2 — test 1 kills M52, test 2 kills my N13 Test 2's anchor is real: audio.dart:63-68 reports Diag.fail('audio-play', 'player pool not ready', isCritical: critical) when the pool is empty, and ringtone passes critical: true (:86-87). Test 3 says "mock the vibration channel to throw" but haptics go through package:vibration (Vibration.hasVibrator(), audio.dart:50) and the spec never names the platform channel, so the implementer must find it. Nameable, not blocking.
§4.8 main_test.dart — 1 test Yes Yes — kills my N12 main.dart:48 is builder: (context, child) => MediaQuery.withNoTextScaling(child: child!). Caveat for the implementer: pumping CadenceApp mounts HomeScreen, which starts a Timer.periodic, so the test needs the S7-F3 clock injection or an explicit teardown — the spec does not say so, unlike §4.2.
§4.9 diagnostics_test.dart — 2 tests Yes Yes — the cap test kills a mutation of _max diagnostics.dart:21 is static const int _max = 50; and :29-30 adds then trims, so after 60 calls the log holds 50 and last is the 60th. Implementer note: Diag.fail also writes to Journal (:37), so the test needs Journal.disableForTests(); the spec omits it.
§4.10 modals_test.dart — 3 tests Yes Yes — kills my N01, N02, N03 One citation is wrong: the spec cites modals.dart:518-524 for _commitStep; it is at 520-525. bump and the 0..180 minute clamp are at :428-429, and previewSound is a real parameter (:171, :182), so the rest of the spec is anchored correctly.

S7R-F7 — The logo_test.dart specification claims a guard its own assertion cannot deliver

  • Severity: LOW
  • Location: findings/S7_tests.md §4.6; subject lib/ui/logo.dart:12-16, pubspec.yaml
  • What is wrong: the spec's single test asserts tester.widget<Image>(…).height == 40 and AssetImage.assetName == 'assets/logo/mark_white.png', and says this "catches the asset being dropped from pubspec.yaml". It cannot. assetName is simply the string literal handed to Image.asset; it is identical whether or not the bundle declares the asset. The test is falsifiable — a mutation changing the path string kills it — but the failure mode the spec names it for would sail through.
  • Evidence: 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 aboutassetName`, so the specified assertions stay green.
  • Why it matters for a restaurant kitchen: cosmetic in itself — the header mark vanishing is not a service failure — but a test that advertises a guard it does not provide is the exact defect R8 exists to catch, and this one would enter the suite carrying S7's endorsement.
  • Proposed fix: keep the two assertions, and add the one that actually loads the bytes: expect(() => rootBundle.load('assets/logo/mark_white.png'), returnsNormally); — or assert the declaration directly by reading pubspec.yaml, as version_test.dart:16 already does for the version.
  • How to prove the fix: delete - assets/logo/ from pubspec.yaml on a copy; the amended test goes red, the specified one stays green.

7. Flakiness — S7's result HELD

Five further runs of my own on a pristine copy, all recorded (proof/01_findings/S7_refute/flake/), all TREE_STATE: CLEAN:

Run Command Result
refute_run_1.txt flutter test --reporter expanded 00:09 +123: All tests passed! EXIT_CODE=0
refute_run_2.txt flutter test --reporter expanded 00:05 +123: All tests passed! EXIT_CODE=0
refute_random_1.txt --test-randomize-ordering-seed random → seed 623062910 00:06 +123: All tests passed! EXIT_CODE=0
refute_random_2.txt --test-randomize-ordering-seed random → seed 276753735 00:03 +123: All tests passed! EXIT_CODE=0
refute_seed_20260804.txt --test-randomize-ordering-seed 20260804 00:03 +123: All tests passed! EXIT_CODE=0

Two fresh random seeds and one fixed seed, none of them S7's. The working tree was verified empty before the first run and after the last. Combined with S7's eight runs and my 57 mutation runs (each of which reports exactly 123 executed tests), the order-independence and flake-freedom claims are the best-supported statements in the stream. S7-F19's 350 ms-for-300 ms margin remains a real design weakness worth converting to fake_async, but it has not produced a flake in 13 independent runs.


8. TREE_STATE audit of S7's proof files

Requested: confirm the sequence is clean throughout and name any file where it is not.

The sequence cannot be confirmed from S7's records, because the stamp is absent from every one of them. 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

That is 0 of 69 — all 57 mutation captures, the 5 flake runs, the 3 randomised runs, the baseline copy run, strict_r8_analysis.txt, revert_clean_check.txt and subject_repo_untouched.txt. The stamp is present in 68 files elsewhere under proof/01_findings/, so S7's runs simply predate the version of run_and_record.sh that emits it. Reported as S7R-F3.

The hole is closed, not merely flagged. My 57 re-runs carry the stamp (57 of 57, reading TREE_STATE: DIRTY (1 path(s) modified) during each mutation and CLEAN in the flake runs), and rerun_results.json records the working tree before apply, after apply and after revert for every mutation: clean, exactly one path, clean. All 57 verdicts reproduce. No S7 result was corrupted by a carried-over mutation — the defect is evidentiary, not substantive.


9. Coverage manifest — all 13 test files in scope, read end to end at 03a176e

# File Lines What I checked in it
1 test/announcement_test.dart 267 All 13 tests read. Confirmed every assertion uses literals ('Frites est prêt', 'The fries is ready') rather than values derived from I18n, so they are not tautological. Confirmed the four widget tests drive the real showTimerEditor. Established the file is reddened by no mutation in the 57 (S7R-F6) and corrected S7's manifest row, which implies otherwise. Verified Store.wasGeneratedByUs and Store.seedLangFor are asserted against 11 explicit inputs including null and ''. No Diag.reset/Journal.disableForTests (S7-F18 confirmed).
2 test/backstop_test.dart 191 All 6 tests read. Verified S7-F4 at source: the guard alarm_backstop.dart:181 widened by ten minutes (M44) leaves G1 green because -3500 ms does not satisfy at <= now - 600000, so the plugin rejects the past date and :204-207 catches it non-critically. Verified S7-F5: engineWith (:70-79) builds only a single-step TimerDef, and line_execution.json shows the chain-summation loop at alarm_backstop.dart:109 at 0 hits. Verified S7-F6: no test removes a timer and re-syncs. Verified S7-F7: no assertion on notification ids. Confirmed the mock sits at the plugin channel (:33), not around Backstop. 4 mutations redden this file; 3 of them alone (M43, M45, M48).
3 test/editor_layout_test.dart 79 Both declarations read; the for (final lang …) loop at :36 registers two distinctly named tests, both executed. Actively tested S7's §3 claim that the duplicated presetLabel helper (:18-20) "fails closed" by mutating the production chip builder at modals.dart:303 to pad to three digits: both language variants went red (extension/N05.json.txt). Claim confirmed. C.presets is read live, not copied.
4 test/engine_test.dart 341 All 21 tests read. Verified S7-F2 verbatim at :61, :145, :166, :172 — every alarm-lead assertion derives its instant from Engine.alarmLeadMs, and M03 leaves 123 green. Verified S7-F11 verbatim at :288-295: the test spawns a clone, discards the id, and asserts e.soundFor(t) on the parent — true before spawnClone is called. Confirmed FakeHost records rather than stubs and owns the clock (:8, :16). Confirmed by json that the neighbouring :212 test does use the returned cid. Source of 5 of the 19 individually pinned tests.
5 test/grid_layout_test.dart 152 All 13 tests read. Independently recomputed GridLayout.solve(1280, 740, 2) from grid_layout.dart:100-108: tileW 602, rowH 512 at maxAspect 0.85 and 572 at 0.95 — the geometry genuinely changes inside the tested list [1, 2, 4, 6, 12] and the test at :72 still passes, confirming S7-F12. Additional observation S7 does not list: :63 expect(l.rows, (n / l.cols).ceil()) is a self-consistency check on two returned fields, of the same weak class as the six S7 names. Confirmed :110-118 (602×332 at 4 timers) and :77-88 (the column table) are genuine goldens — M51 died alone on the latter.
6 test/i18n_defaults_test.dart 117 All 6 tests read. Verified S7-F13 on this machine: test -f assets/audio/Chirp.wav resolves although the file is chirp.wav, so existsSync() at :75 and lengthSync() at :76 are both blind to case and M52 survives. Verified S7-F14: no ttsLocale assertion anywhere, and line_execution.json shows i18n.dart:166 at 0 hits. Confirmed the tone list is read from C.tones, not copied. Established the file is reddened by no mutation in the 57 (S7R-F6). Working-directory coupling at :74 confirmed (S7-F17).
7 test/journal_test.dart 193 All 11 tests read. Confirmed real files in a real temp directory with PathProviderPlatform overridden (:12-21, :31) and tearDown cleaning up. Verified :148-164 asserts the death stamp advances on an unwritten beat — M54 died alone there, result: "failure", mode A. Real delays at :115 (50 ms) and :160 (20 ms) reviewed against 13 independent green runs.
8 test/robustness_test.dart 314 All 16 tests read. Verified the second alarm-lead tautology verbatim at :290-291. Verified all seven S7 line citations (:167, :189, :205, :224, :242, :263, :291) resolve to the tests S7 names. Confirmed the batch-label group asserts stable literal numbers. Source of 5 of the 19 individually pinned tests. Noted :309-311 documents that a single-step "chain" loses its 30 s and becomes a 5 s timer — behavioural, owned by S1, not a test defect.
9 test/source_hygiene_test.dart 25 The single test read. Asserts a non-empty offender list with a reason (:22-23); exercises no lib/ symbol by design, so its absence from every mutation red set is correct and not a defect. Working-directory coupling at :11 confirmed (S7-F17).
10 test/store_test.dart 220 All 10 tests read. Confirmed corrupt-preservation assertions use literal stored strings (:101-102). Source of 4 of the 19 individually pinned tests (M23/M29 → :126, M30 → :172, M31 → :204). Confirmed no Journal.disableForTests() (S7-F18). Verified S7-F15 at source: :83 stores a double under cadence-lang, which is served by _readString's type catch, not by the fr/en validation at store.dart:158-161 — and line_execution.json shows that line executed 5 times with M32 still surviving, so it is executed-and-unasserted, the strongest class of survivor.
11 test/version_test.dart 29 The single test read. Genuine: it parses pubspec.yaml independently of kAppVersion (:16-27). Exercises no lib/ code path, so its absence from every red set is expected. Working-directory coupling at :16 confirmed.
12 test/voice_test.dart 197 All 9 tests read. Confirmed the Completer mock at :27-28 genuinely holds an utterance open. Verified S7-F8 at source and by execution counts: the 300 ms gap (voice.dart:177, 2 hits), the staleness rule (:184, 3 hits) and the generation guard (:174, 2 hits) are all executed by the suite and all three mutations still survive — this is not a coverage gap but an assertion gap, which strengthens the finding. M41 died alone on :118; M42 on :156 and :170.
13 test/volume_test.dart 188 All 13 tests read. Confirmed the strongest file: 3 of 3 AlarmVolume mutations plus Store.vol killed, M33 (:46) and M37 (:103) each alone with result: "failure". Confirmed make() records every pushed level in order (:20-23) rather than asserting on internals. One correction to S7: the widget test at :160 that catches the floor change (M35) is reported by the json stream as result: "error", not "failure" — its cause is a TestFailure but every testWidgets failure arrives as error (S7R-F2). It does not affect the 19, none of which is a widget test.
Total 2,313 13 of 13 files read end to end

10. What I did not overturn

I tried to break these and could not:

  • the 57 verdicts (all reproduced, 0 disagreements);
  • the 22-of-32 strict-R8 count and the 19 distinct tests (both reproduced from the json stream);
  • the 57-distinct-patch claim (57 distinct SHA-256 hashes of 57);
  • "no mutation was compile-breaking" (123 testStart events and 0 load failures in all 57 of my runs);
  • flake-freedom and order-independence (5 more runs, 2 fresh seeds);
  • S7-F2, F4, F5, F8, F11, F12, F13, F15 — each verified verbatim at source, and F5, F8 and F15 strengthened by execution counts S7 did not measure;
  • S7's §3 claim that editor_layout_test.dart's duplicated label helper fails closed — I mutated the production expression and both variants went red;
  • all file:line citations I checked, with one exception: modals.dart:518-524 for _commitStep, which is at 520-525.

Stream S8: finding and refutation

S8 — Internationalisationfindings/S8_i18n.md · raw .md

S8 — Internationalisation

Stream: S8 · Subject: the app repository @ 03a176e72ef0075eec86b8915cbe6e93042a3b9d (v0.4.12+18) Evidence root: proof/01_findings/S8/ Mode: read-only (R10). No file under cadence-app was created, edited, or deleted; no branch was switched; no git command that writes was run.

Method note on the probes. Forcing a missing-key and an unsupported-locale path requires running code, and R10 forbids adding a test file to the subject. Every probe therefore ran against a byte-identical copy of the tree at <scratch>/cadence-probe, with only test/zz_probe_*.dart added. The copy is proved identical for all of lib/ and test/ in proof/01_findings/S8/probe_tree_identical.txt (SHA-256 of the sorted per-file SHA-256 list, both trees, IDENTICAL: yes). The unmodified 123-test suite still passes in that copy (proof/01_findings/S8/full_suite_unaffected.txt).

Scope boundary vs the CHECKLIST partition. CHECKLIST.md:61 makes S8 primary on lib/i18n.dart only. The coordinator's brief widened this stream to "every user-visible string anywhere in lib/, android/app/src/main/res/values*/, ios/Runner/Info.plist (CFBundleDisplayName) and web/manifest.json", which is what §3 and §4 cover. Where a finding lands in a file another stream owns, it is stated only along the i18n dimension and never restates that stream's dimension: lib/main.dart (S14) is examined for localisation configuration, not startup ordering; lib/ui/*.dart (S4) for string content, not layout — the text-expansion input for S4 is handed over in §5 rather than assessed here; lib/engine/store.dart (S2) for the language and seed literals, not persistence integrity; AndroidManifest.xml and Info.plist (S9) for the display name only, not signing or build config; web/manifest.json (S6) for the name and description only.


Summary of the four answers the coordinator asked for

Question Answer
Locale count 2fr and en. Nothing else exists anywhere in the product.
Key-parity gaps Zero. 37/37 chrome keys and 12/12 tone labels are present in both, and 12/12 tones in C.tones are labelled in both.
Hardcoded user-visible strings bypassing i18n.dart 32 distinct strings in lib/, enumerated with file:line in §3, plus the whole 43-call-site French-only journal surface, plus the Flutter framework's own English strings (§ S8-F1).
Fallback on a missing key The raw key identifier is rendered on screen. I18n('fr').call('thisKeyDoesNotExist') returns the literal string thisKeyDoesNotExist. It does not throw, does not return empty, and does not fall back to English.

1. Which locales exist, and how the app chooses one

Two, and only two. I18n._strings (lib/i18n.dart:39-132) and I18n.toneLabels (lib/i18n.dart:8-37) each hold exactly the keys fr and en (proof/01_findings/S8/probe_i18n.txt, PROBE-1):

LOCALES_IN_strings=[en, fr]
LOCALES_IN_toneLabels=[en, fr]

There is no Flutter localisation machinery at all. pubspec.yaml declares neither flutter_localizations nor intl, and lib/ contains no localizationsDelegates, no supportedLocales, and no Locale( constructor (proof/01_findings/S8/no_localised_resources.txt). There is no android/app/src/main/res/values-fr/, no strings.xml anywhere in android/, no Localizable.strings or InfoPlist.strings in ios/, and only ios/Runner/Base.lproj.

Selection is a one-time decision, written to storage, never revisited. Three places in the code own it.

The language a fresh install starts in, from the OS locale (lib/engine/store.dart:293-294):

static String seedLangFor(String? deviceLang) =>
    deviceLang?.toLowerCase().startsWith('fr') == true ? 'fr' : 'en';

read from the platform at lib/engine/store.dart:346-347:

lang = seedLangFor(
    deviceLang ?? PlatformDispatcher.instance.locale.languageCode);

The stored preference, which is what the running app actually reads (lib/engine/store.dart:158-161):

String get lang {
  final v = _readString(_kLang);
  return (v == 'fr' || v == 'en') ? v! : 'en';
}

And the single place the running app binds it (lib/ui/home.dart:84): i18n = I18n(widget.store.lang); — evaluated once in initState, so the language changes only through the Settings buttons (lib/ui/home.dart:473-477), which write both the live object and the store.

Measured behaviour (PROBE-5):

seedLangFor("fr")=fr   seedLangFor("fr-CA")=fr   seedLangFor("FR")=fr
seedLangFor("en")=en   seedLangFor("de")=en   seedLangFor("es")=en   seedLangFor("ar")=en
seedLangFor(null)=en   seedLangFor("")=en
Store.lang stored={cadence-lang: fr} -> "fr"
Store.lang stored={cadence-lang: de} -> "en"
Store.lang stored={} -> "en"
FRESH_INSTALL_deviceLang=de -> Store.lang="en"
EXISTING_DATA_NO_SEED_FLAG deviceLang=fr seeded=false Store.lang="en"

The last line is the defect recorded as S8-F4.

What test/i18n_defaults_test.dart actually asserts

Five assertions, all real, none redundant (test/i18n_defaults_test.dart:37-83):

  1. :38-45 — FR and EN key sets are equal, in both directions, with the offending keys in the failure reason. This is the parity test, and it works.
  2. :49-56 — every tone in C.tones has a label in both languages. It reads C.tones, not a copy — the comment at :47-48 records that the list used to be duplicated here, so adding a tone could not fail the test.
  3. :58-66 — no two tones show the same display label within one language.
  4. :70-78 — every tone in the picker has a .wav on disk, non-empty.
  5. :80-82I18n('fr').call('doesNotExist') returns 'doesNotExist'. This asserts the missing-key fallback, and it asserts that the fallback is the key itself — see S8-F8.

What it does not assert: anything about locales other than fr/en; anything about the framework's own strings; anything about the 32 hardcoded literals; anything about string length.


2. Completeness parity — enumerated, not eyeballed

Both maps enumerated programmatically (PROBE-1):

Set FR count EN count Only in FR Only in EN
I18n._strings chrome keys 37 37 {} {}
I18n.toneLabels tone keys 12 12 {} {}
C.tones not labelled {} {}

There is no key-parity gap. This is the one dimension of the i18n layer that is genuinely correct, and it is correct because test/i18n_defaults_test.dart:38-45 enforces it on every run.


3. Hardcoded user-visible strings — the full inventory

The code map's §3.6 inventory was verified line by line and extended. Every row below was confirmed by opening the file. Sites the code map had not recorded are marked NEW.

# String (verbatim) Site (valid at 03a176e) What a French user sees What an English user sees
1 'Cadence — Kitchen Timer' lib/main.dart:44 English (Android task-switcher label) English
2 'CADENCE' lib/ui/header.dart:67 proper noun — correct in both correct
3 ' — Kitchen Timer' lib/ui/header.dart:79 English descriptor in the header, above 960 px correct
4 '⚙' lib/ui/header.dart:97 glyph — language-neutral glyph
5 ':' lib/ui/header.dart:146 clock colon clock colon
6 '◷' lib/ui/home.dart:708 glyph glyph
7 '✎ EDIT' lib/ui/tile.dart:552 English badge on every tile in edit mode correct
8 '+' / '−' / '10' lib/ui/tile.dart:602,603,611,612 digits and signs same
9 '✕' lib/ui/tile.dart:619, lib/ui/modals.dart:510 glyph glyph
10 '${t.name.toUpperCase()} #${widget.batchNo}' lib/ui/tile.dart:402 operator's own text same
11 '🗑' lib/ui/modals.dart:348 glyph glyph
12 '▲' / '▼' lib/ui/modals.dart:395,413 glyph glyph
13 ':' ×2 lib/ui/modals.dart:432,500 separator separator
14 NEW 'min' → rendered MIN lib/ui/modals.dart:428, rendered at :406 Text(unit.toUpperCase(), …) MIN — coincidentally the correct FR abbreviation, so no visible defect today MIN
15 NEW 'sec' → rendered SEC lib/ui/modals.dart:436, rendered at :406 SEC — coincidentally correct in FR SEC
16 'Phase' lib/ui/modals.dart:482 English placeholder in every chain-step name field (FR would be Étape) correct
17 'Step' lib/ui/modals.dart:312,373 a new step is named Step, in English, on a French tablet correct
18 'Sear', 'Rest' lib/ui/modals.dart:276-277 the two default chain steps are English cooking verbs correct
19 'Timer' lib/ui/modals.dart:364, lib/engine/engine.dart:365, lib/engine/models.dart:67 a timer saved with a blank name is called Timer correct
20 'Step' lib/engine/models.dart:22 JSON-decode fallback for a nameless step correct
21 '🇬🇧', 'English', '🇫🇷', 'Français' lib/ui/modals.dart:624,626 correct by design (each language names itself) correct
22 '${(vol * 100).round()} %' lib/ui/modals.dart:637 50 % — correct FR typography 50 % — wrong in English, which takes no space (see S8-F7)
23 '⏰ $name' lib/alarm_backstop.dart:186 operator's own text same
24 '⏰ ${t.name}' lib/alarm_backstop.dart:259 operator's own text same
25 'Timer alarms' lib/alarm_backstop.dart:47 English channel name in Android Settings → Notifications correct
26 'Rings when a timer expires while the app is not on screen' lib/alarm_backstop.dart:48-49 English channel description in Android Settings correct
27 'Manouche', 'Mozzarella sticks', 'Fries', 'Crispy', 'Melt cheese', 'Dough', 'Cook chicken', 'Cook', 'Flip' lib/engine/store.dart:327-341 the whole first-launch board is English (see S8-F3) correct
28 '$name [lot ${c.batchNo}]' lib/engine/engine.dart:137 lot is French; leaks into English journals French token in an English context
29 '?' lib/engine/engine.dart:135 unknown-id fallback same
30 '#${engine.nextBatchNo(pid)}' lib/ui/home.dart:638 digits digits
31 'Cadence log — … — dd/MM HHhmm' lib/ui/modals.dart:707-708 share-sheet subject; HHhmm is the FR convention mixed: English word log, French time format
32 'Journal de bord Cadence v…\nAppareil : …\n' lib/ui/modals.dart:709-710 correct French body text in the English share sheet

32 distinct strings. Their sites fall in nine files: lib/ui/modals.dart (21 lines), lib/engine/store.dart (the seed block :327-341), lib/ui/tile.dart (7), lib/alarm_backstop.dart (5), lib/ui/header.dart (4), lib/engine/engine.dart (3), lib/engine/models.dart (2), lib/ui/home.dart (2), lib/main.dart (1). The recorded grep of every one of them is proof/01_findings/S8/hardcoded_string_sites.txt.

Beyond the table, the entire exported journal is French-only prose and is not routed through i18n.dart: 43 Journal.log(...) sites plus the session header built at lib/journal.dart:82-94, :187, :198, :211. An English-market operator who taps Send the log mails a French document (proof/01_findings/S13/probe_data.txt, PROBE-C shows the exact payload).


4. Findings

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

  • Severity: HIGH
  • Location: lib/main.dart:43-56 (valid at 03a176e)
  • What is wrong: MaterialApp is constructed with no localizationsDelegates, no supportedLocales, and no locale. Flutter therefore resolves DefaultMaterialLocalizations, an English-only implementation, and pins the ambient locale to en_US regardless of the tablet's OS language and regardless of the Cadence language switch. Cadence's own 37 strings translate; every string the framework draws does not. The concrete surface is the text-selection toolbar, which appears whenever a cook long-presses the timer-name field, the announcement field, or a step-name field in the editor (lib/ui/modals.dart:250-258, 336-343, 486-492).
  • Evidence: 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:

### Flutter localisation packages in pubspec
(exit 1 — 1 means absent)
### MaterialApp localisation arguments in lib/
lib/ui/home.dart:477:        voice.setLocale(i18n.ttsLocale);
lib/audio/voice.dart:80:  Future<void> setLocale(String locale) async {

(the two hits are the TTS locale, not Flutter localisation.) - Why it matters for a restaurant kitchen: a French cook renaming a dish mid-service gets an English Couper / Copier / Coller menu that reads Cut / Copy / Paste / Select all. It is the single most visible "half-translated app" tell, it appears in exactly the moment a new cook is being shown how to add a timer, and it is what a one-star French review is written about. - Proposed fix: add flutter_localizations to pubspec.yaml, then on the MaterialApp at lib/main.dart:43: localizationsDelegates: GlobalMaterialLocalizations.delegates, supportedLocales: const [Locale('fr'), Locale('en')], and locale: Locale(store.lang) so the framework follows the Cadence switch rather than the OS. No new user-facing capability (R6): the language switch already exists; this makes the existing switch reach the strings it currently misses. - How to prove the fix: a widget test that pumps CadenceApp(store: store) with store.lang == 'fr' and asserts MaterialLocalizations.of(ctx).pasteButtonLabel == 'Coller'. Red today (returns Paste), green after.


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

  • Severity: HIGH
  • Location: lib/i18n.dart:148-150; migration at lib/engine/store.dart:187-231 (valid at 03a176e)
  • What is wrong: readyPhrase hard-codes one grammatical form per language:
String readyPhrase(String name) => lang == 'fr'
    ? '$name est prêt'
    : 'The ${name.toLowerCase()} is ready';

The French form is masculine singular, always. The English form is singular, always, and lower-cases the name. Until v0.4.7 the seed shipped hand-written phrases that had the plural right — 'Mozzarella sticks': 'The mozzarella sticks are ready', 'Fries': 'The fries are ready' (lib/engine/store.dart:188-195). repairGeneratedPhrases (:219-231) recognises exactly those strings via wasGeneratedByUs (:201-204) and clears them, so the announcement is now regenerated by readyPhrase — which gets three of the seven wrong. - Evidence: 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. - Why it matters for a restaurant kitchen: the announcement is the product's voice. A board that says "Pizza est prêt" and "Frites est prêt" all service sounds like machine translation to every French cook in the room, and to every manager who walks past. It is also the one thing a prospect hears in a 30-second demo. The English regression is worse in kind: v0.4.7 actively downgraded a correct sentence to an incorrect one, and no test caught it. - Proposed fix: 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 the fix: 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

  • Severity: HIGH
  • Location: lib/engine/store.dart:326-343 (valid at 03a176e)
  • What is wrong: seedIfFresh sets the chrome language from the tablet locale (:346-347) but the seven seeded timer names and the three step names are English literals with no language branch. A French tablet therefore boots with French buttons and an English menu.
  • Evidence: 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"
  • Why it matters for a restaurant kitchen: the first thirty seconds decide whether the tablet gets used. A French kitchen opens Cadence and the board reads FRIES, CRISPY, MELT CHEESE, DOUGH, and the French text-to-speech voice pronounces those English words inside a French sentence — "Melt cheese est prêt" — which in a noisy kitchen is neither understood nor trusted. Prior work already flagged the commercial half of this (research/01_prior_work.md §2.4, v2 item §12: "a store-facing product seeded with one kitchen's menu is a live question"). This finding adds the linguistic half: the seed is not just one kitchen's menu, it is one kitchen's menu in the wrong language.
  • Proposed fix: 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 the fix: 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.

S8-F4 — Language is chosen once, and an install that never seeded is pinned to English forever

  • Severity: MEDIUM
  • Location: lib/engine/store.dart:300-310 and :158-161 (valid at 03a176e)
  • What is wrong: cadence-lang is written in exactly one place other than the Settings buttons: inside seedIfFresh, after the early returns. The guard at :305-310 — correct and deliberate, it is decision A1-2 protecting a real kitchen from the demo seed — returns before lang is ever assigned:
if (e.timers.isNotEmpty || _readString(_kZones) != null) {
  _guard(_kSeeded, prefs.setBool(_kSeeded, true));
  return false;
}

Any install that reaches this branch (a pre-v0.4.11 tablet whose seeded flag was lost, a restore from backup into a fresh app data directory, a sideloaded APK over existing data) never gets a language written, and Store.lang then returns its fallback 'en' (:160). The OS locale is never consulted again for the lifetime of the install. - Evidence: 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"
  • Why it matters for a restaurant kitchen: a French tablet that has been through a restore silently comes back in English, mid-service, with no message and no obvious cause. The cook's recourse is to find Settings → Langue, which is written in English at that moment.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: android/app/src/main/AndroidManifest.xml:22; ios/Runner/Info.plist (CFBundleDisplayName, CFBundleName); web/manifest.json:2-3; lib/main.dart:44 (valid at 03a176e)
  • What is wrong: five different display names ship in one build.
  • Evidence: 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. - Why it matters for a restaurant kitchen: less about the kitchen than about the store. Apple and Google both compare the store-listing name against the bundle's display name, and a lower-case cadence in CFBundleName next to Cadence on the home screen is the kind of mismatch that draws a review question. Commercially, README.md:38 records that the name itself is unresolved — «Nom « Cadence » = placeholder à figer avec the project owner» — so this list is also the rename inventory. - The complete list a name change must touch (for the business-decision work):

  1. android/app/src/main/AndroidManifest.xml:22android:label
  2. android/app/build.gradle.kts:8namespace
  3. android/app/build.gradle.kts:21applicationId (immutable after the first Play publish)
  4. android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt — package path and declaration
  5. ios/Runner/Info.plistCFBundleDisplayName
  6. ios/Runner/Info.plistCFBundleName
  7. ios/Runner.xcodeproj/project.pbxprojPRODUCT_BUNDLE_IDENTIFIER (immutable after the first App Store publish)
  8. web/manifest.json:2,3,8name, short_name, description
  9. web/index.html:21,32<meta name="description">, apple-mobile-web-app-title, <title>
  10. pubspec.yaml:1,2name: (the Dart package name — changes every package:cadence/… import in lib/ and all 13 test files), description:
  11. lib/main.dart:44MaterialApp.title
  12. lib/ui/header.dart:67,79 — the on-screen wordmark and descriptor
  13. lib/alarm_backstop.dart:46 — notification channel id 'cadence-alarms' (changing it orphans the channel on already-installed tablets: the old channel keeps the operator's sound and importance settings and the new one starts at defaults)
  14. lib/alarm_backstop.dart:55 — raw resource name 'cadence_alarm' and android/app/src/main/res/raw/cadence_alarm.wav
  15. lib/engine/store.dart:15-24 — nine cadence-* preference keys (changing them loses every kitchen's configuration unless a migration is written)
  16. lib/journal.dart:23,24 — two more cadence-* preference keys
  17. lib/journal.dart:70 — the journal filename cadence-journal.txt
  18. lib/journal.dart:221 — the exported filename pattern cadence-log-…
  19. lib/ui/modals.dart:707,709 — the share-sheet subject and body
  20. lib/journal.dart:83 — the journal session header
  21. assets/logo/mark_white.png and assets/icon/ic_*.png — the artwork itself
  22. README.md:1,38 - Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/alarm_backstop.dart:46-49 (valid at 03a176e)
  • What is wrong: the backstop channel is created with literal English:
static const _channel = AndroidNotificationChannel(
  'cadence-alarms',
  'Timer alarms',
  description: 'Rings when a timer expires while the app is not on screen',

These two strings are what Android shows in Settings → Apps → Cadence → Notifications, and the channel name is drawn under the notification on the lock screen. They cannot be changed after the channel is created without deleting and recreating it, which resets the operator's per-channel sound and importance. - Evidence: 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(). - Why it matters for a restaurant kitchen: this is precisely the screen a French chef is sent to when the backstop alarm does not ring — the operator banner says «⚠️ Alarme de secours indisponible» (lib/i18n.dart:79-80) and the fix is in Android Settings, which then presents Timer alarms in English. The one recovery path the app points at is untranslated. - Proposed fix: 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. - How to prove the fix: 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).


S8-F7 — Numbers, durations and the clock are formatted by hand, with two concrete defects

  • Severity: MEDIUM
  • Location: lib/ui/theme.dart:77-82; lib/ui/header.dart:143-148; lib/ui/modals.dart:637 (valid at 03a176e)
  • What is wrong: three separate hand-rolled formatters, none locale-aware.

(a) Duration has no hour component. fmtTime (lib/ui/theme.dart:77-80) is '${v ~/ 60}:${(v % 60).toString().padLeft(2, '0')}'. The editor lets a timer run to 180 minutes (lib/ui/modals.dart:428: min < 180 ? min + 1 : 180), and fmtTime is what draws the DSEG7 digits on every tile (lib/ui/tile.dart:183,190,197).

(b) The percent sign carries a hard-coded space. lib/ui/modals.dart:637: Text('${(vol * 100).round()} %', …). French typography wants that space; English does not. Worse, the English string three lines below it in the same dialog writes it the other way — lib/i18n.dart:115: 'Minimum 15%: at 0% you would no longer hear your timers ring.' So the English Settings screen shows 50 % immediately above Minimum 15%.

(c) The header clock is 24-hour, unconditionally. lib/ui/header.dart:145-147 builds it from now.hour.toString().padLeft(2, '0'), and never consults MediaQuery.alwaysUse24HourFormat or any locale. Because S8-F1 pins the ambient locale to en_US, even a framework-based decision would have chosen 12-hour. - Evidence: 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
  • Why it matters for a restaurant kitchen: 24-hour is correct for France and is the right default for a kitchen anywhere — cooks read service times in 24-hour. It is wrong for a US listing, where a 12-hour clock is the expectation, and that is a store-listing decision rather than a bug. The duration format is a real operational defect: a 120:00 on a tile is read as "one hundred twenty" only after a pause, where 2:00:00 is instant. The 50 % next to 15% inside one dialog is the visible tell that nobody proof-read the English.
  • Proposed fix: (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 the fix: 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

  • Severity: LOW
  • Location: lib/i18n.dart:143-145 (valid at 03a176e)
  • What is wrong: the lookup is String call(String key) => (_strings[lang] ?? _strings['en'])![key] ?? key;. The ! applies to the null-coalesced map, so an unknown language is safe. But a key missing from the resolved map falls through to ?? key, which puts a camelCase developer identifier into the interface. It does not fall back to the other language, which would degrade far more gracefully.
  • Evidence: 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. - Why it matters for a restaurant kitchen: today, nothing — parity is perfect (§2) and test/i18n_defaults_test.dart:38-45 keeps it that way, so no key can go missing without the suite going red. The severity is LOW for exactly that reason. It is recorded because the behaviour is the wrong default for the day someone adds a key to en only with the parity test deleted or skipped: an operator would see backstopDown on a banner instead of a warning. - Proposed fix: change the fallback chain to try English before surrendering: _strings[lang]?[key] ?? _strings['en']![key] ?? key. Two-token change, same signature. - How to prove the fix: 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).


5. Text expansion — the linguistic input for S4

French is 19.8 % longer than English overall across the 37 chrome strings (887 → 1,063 characters). Handing S4 the ranked list rather than duplicating its layout work. Full table: proof/01_findings/S8/probe_i18n.txt, PROBE-2.

The strings S4 should stress first — every one sits inside a fixed-width control (a header button, a modal button, or a chip) rather than a wrapping paragraph:

Rank Key EN FR Growth Widget it lives in
1 save Save (4) Enregistrer (11) +175 % _modalBtnlib/ui/modals.dart:358, primary editor button, shares a row with cancel
2 addStep + Add step (10) + Ajouter une étape (19) +90 % full-width chip — lib/ui/modals.dart:311
3 new + New (5) + Nouveau (9) +80 % _HBtnlib/ui/header.dart:114; the header has a four-step width ladder at 960/820/800/560 px (lib/ui/header.dart:33-37) and this is the button that collapses to its glyph at 800 px or below
4 saveFail 43 68 +58 % operator banner — lib/ui/home.dart:680; banners stack, so this one plus loadFail is 140 French characters
5 done ✓ Done (6) ✓ Terminé (9) +50 % _HBtnlib/ui/header.dart:105, swaps in for edit in edit mode, so the header width changes when the mode toggles
6 newTimer 9 13 +44 % modal title _h2lib/ui/modals.dart:247
7 emptyTitle 25 35 +40 % empty-board headline, .toUpperCase()lib/ui/home.dart:711
8 journalSend 📤 Send the log (15) 📤 Envoyer le journal (21) +40 % _modalBtnlib/ui/modals.dart:678
9 silentNote 44 61 +39 % concatenated with volumeFloor into one Textlib/ui/modals.dart:666 — so the French Settings body is 124 characters
10 edit ✎ Edit (6) ✎ Éditer (8) +33 % _HBtnlib/ui/header.dart:105

Two amplifiers S4 must account for, both in code:

  • _HBtn and _settingLabel upper-case their text (lib/ui/header.dart:202, lib/ui/modals.dart:721). ENREGISTRER is wider than Enregistrer at the same point size, and _settingLabel adds letterSpacing: 2.3.
  • Text scaling is disabled globally. lib/main.dart:48 wraps the whole tree in MediaQuery.withNoTextScaling, so nothing shrinks to fit and nothing grows with the OS setting — every overflow is a hard clip. The header sets overflow: TextOverflow.clip, softWrap: false explicitly at lib/ui/header.dart:90-92.

Tone labels expand far less (largest is ChimeCarillon, 5 → 8 characters) and live in a picker that already handles them; not a stress candidate.


6. Store-listing consequence

The app supports French and English, and nothing else. Concretely, given the findings above:

  • Write the listing in French and English only. A third listing locale would send a user to an app whose chrome falls back to English (PROBE-4) with no in-app way to reach their language.
  • The French listing is currently writing a cheque the app does not cash. A French screenshot set will show FRIES, CRISPY, MELT CHEESE on the board (S8-F3) unless the seed is translated or the screenshots are taken on a hand-configured tablet. Taking the screenshots on a configured tablet ships a listing that does not match first launch — which is the review-risk version of the same problem.
  • The English listing must not claim localisation. Between S8-F1 (framework strings), S8-F6 (notification channel), and the French-only journal export, an English-market operator meets French and untranslated English in three places.
  • Do not localise the store listing beyond the two. Both stores let a developer add locales cheaply; doing so here converts a two-locale product into a visibly broken n-locale one.
  • The 24-hour clock (S8-F7c) is right for a France-first launch and should be stated as a deliberate choice, not left as an accident of now.hour.

7. What I checked and found nothing

  • Right-to-left. No RTL locale exists and none is planned; Directionality is never set in lib/. Nothing to report — but note that with S8-F1 unfixed, adding one would not work.
  • Key parity. Enumerated both maps in both directions; zero gaps (§2). This scoped area is clean.
  • Tone-label collisions. test/i18n_defaults_test.dart:58-66 already enforces uniqueness per language, and PROBE-1 confirms 12/12 labelled in both. Nothing to report.
  • Unsupported-locale crash. Forced six unsupported values through call, toneLabel, readyPhrase and ttsLocale (PROBE-4). No throw on any path. The ! at lib/i18n.dart:143 and :145 is safe as written.
  • Android localised resources. Checked for values-* language qualifiers, strings.xml, Localizable.strings, InfoPlist.strings, and .lproj bundles. None exist, so there is no platform-resource parity problem to report — the app has no platform-resource strings at all except android:label.

8. Coverage manifest

Every file in scope, its line count, and what was checked in it.

File Lines What I checked
lib/i18n.dart 167 Read in full. Enumerated both maps programmatically (PROBE-1). Forced missing-key and unsupported-locale fallbacks (PROBE-3, PROBE-4). Traced readyPhrase/announcementFor grammar for 10 names in both languages (PROBE-H, PROBE-I). Verified the ! at :143,145 cannot throw. Verified ttsLocale (:166) is a two-value branch.
lib/main.dart 58 Read in full. MaterialApp localisation arguments inspected on the live widget (PROBE-6). MaterialApp.title recorded as a hardcoded string. MediaQuery.withNoTextScaling at :48 recorded as a text-expansion amplifier for S4.
lib/engine/store.dart 354 Read in full. Traced every write and read of cadence-lang (:23,159,163,346). Ran seedLangFor over 9 device locales and Store.lang over 5 stored values (PROBE-5). Ran a genuine fresh French install and the no-seed-flag path (PROBE-5, PROBE-G). Recorded the 10 English seed literals at :327-341 and the 7 English _seededPhrases at :188-194.
lib/ui/modals.dart 746 Every string literal extracted mechanically and each user-visible one located. Read :390-445 (duration picker, MIN/SEC), :590-746 (Settings, language buttons, volume readout, journal share). Recorded 16 hardcoded strings.
lib/ui/header.dart 215 Read in full. Clock construction at :143-148 (24-hour, hand-built). Wordmark and descriptor at :64-95. _HBtn upper-casing at :202 and the 470/560/800/820/960 px width ladder at :33-37, both recorded as text-expansion amplifiers.
lib/ui/tile.dart 819 String literals extracted mechanically; '✎ EDIT' (:552), '✕' (:619), '+'/'−'/'10' (:602,603,611,612), name plus batch number (:402) recorded. Traced fmtTime/fmtUp consumption at :183,190,197,200 and the DSEG7 render at :472-491.
lib/ui/theme.dart 82 Read in full. fmtTime/fmtUp (:77-82) exercised over 7 inputs including the 180-minute editor ceiling (PROBE-8). Confirmed 0.00 % baseline coverage, so no existing test constrains them.
lib/ui/home.dart 722 Grepped every i18n/lang/locale site (28 hits) and read each. i18n = I18n(store.lang) at :84; language switch at :473-477; banner assembly at :670-690; empty-board emptyTitle.toUpperCase() at :711; batch chip at :638.
lib/alarm_backstop.dart 279 Notification channel name and description at :46-49 and its single creation site at :78-80. Notification titles at :186,259. Confirmed the localised body text is passed in from i18n.call('notifRinging') (lib/ui/home.dart:152,190,199,256,300).
lib/engine/engine.dart 432 labelFor at :132-142 ('?' fallback, '[lot N]' suffix) and the 'Timer' default name at :365.
lib/engine/models.dart 160 'Step' and 'Timer' JSON-decode fallbacks at :22,67. kDefaultSound = 'Chirp' at :27 confirmed to be an identifier, not a label.
lib/journal.dart 250 Read in full for the export surface: session header :82-94, device line :84, kill line :88-90, EXPORT line :211, filename pattern :221-223. All French, none routed through I18n.
lib/diagnostics.dart 54 Journal.log lines at :37,44 ('!! PANNE CRITIQUE', '! panne', ' retablie') — French, unrouted, exported.
lib/audio/voice.dart 204 setLocale/init take i18n.ttsLocale (:45,80); confirmed the speech locale follows the app language and is the only place a locale string crosses to native. Journal lines at :75,135,137,165,185 — French.
lib/audio/audio.dart 116 Checked for user-visible strings: none. assetFor (:83-84) maps tone identifiers, not labels.
lib/audio/alarm_volume.dart 68 Checked for user-visible strings: none. Journal line at :62 — French.
lib/ui/grid_layout.dart 109 Checked for user-visible strings: none. Pure geometry.
lib/ui/logo.dart 18 Checked: one Image.asset, no text.
test/i18n_defaults_test.dart 117 Read in full; every one of its five assertions characterised in §1.
android/app/src/main/AndroidManifest.xml 74 android:label="Cadence" at :22; confirmed against the merged release manifest and the shipped APK. Not a @string/ lookup, so there is no localisable app name.
android/app/src/main/res/values*/ 2 dirs values/ (colors.xml, styles.xml) and values-night/ (styles.xml) only. No strings.xml, no language qualifier anywhere.
android/app/build.gradle.kts namespace and applicationId = dev.sergemio.cadence (:8,21), recorded in the rename inventory.
ios/Runner/Info.plist 78 CFBundleDisplayName = Cadence; CFBundleName = cadence; CFBundleDevelopmentRegion = $(DEVELOPMENT_LANGUAGE). No CFBundleLocalizations.
ios/Runner.xcodeproj/project.pbxproj developmentRegion = en, knownRegions = (en, Base) at :196-201. The Xcode project declares one language.
ios/Runner/*.lproj 1 dir Base.lproj only. No localised bundle.
web/manifest.json 33 name/short_name = cadence, description = A new Flutter project., colours #0175C2. Stock Flutter template (prior finding A1-5).
web/index.html 38 <title>cadence</title>, apple-mobile-web-app-title = cadence, description = A new Flutter project.. Stock template.
pubspec.yaml 69 name: cadence, description: line, no flutter_localizations, no intl, no generate: flag.

Files in lib/ not checked for i18n: none. All 18 were checked for user-visible strings.

S8 — REFUTATION (internationalisation)agent_reports/S8_refute.md · raw .md

S8 — REFUTATION (internationalisation)

Role: adversarial refuter under R5. Subject: the app repository @ 03a176e72ef0075eec86b8915cbe6e93042a3b9d (v0.4.12+18). Evidence root: proof/01_findings/S8_refute/. Mode: the pinned repo was never written to — proof/01_findings/S8_refute/pinned_repo_untouched.txt records git status --porcelain returning empty with GIT_HEAD 03a176e… and TREE_STATE: CLEAN. Every probe and every mutation ran on a copy at a scratch working copy with CADENCE_REPO set to the copy, so each proof header stamps the copy's own tree state rather than the original's.

I re-derived every numeric claim from scratch rather than reading S8's probe files, and I mutated the code to test the claims S8 asserted but did not exercise.


Verdict table

Finding S8 severity Verdict Severity after refutation
S8-F1 — no flutter_localizations, framework strings stay English HIGH CONFIRMED HIGH
S8-F2 — readyPhrase ungrammatical in both languages; v0.4.7 downgraded English HIGH CONFIRMED (including the regression, against git) HIGH
S8-F3 — French first launch seeds an English board read by a French voice HIGH CONFIRMED HIGH
S8-F4 — language chosen once; a never-seeded install is pinned to English MEDIUM CONFIRMED MEDIUM
S8-F5 — app name inconsistent across the places an OS reads it MEDIUM CONFIRMED in substance; file:line REFUTED MEDIUM
S8-F6 — Android notification channel is English-only MEDIUM CONFIRMED in substance; EVIDENCE AND FIX REFUTED MEDIUM
S8-F7 — hand-rolled number/duration/clock formatting MEDIUM CONFIRMED MEDIUM
S8-F8 — a missing key renders its own identifier LOW MECHANISM CONFIRMED; SEVERITY REFUTED MEDIUM
§2 — key parity 37/37 and 12/12, zero gaps CONFIRMED independently n/a
§5 — French +19.8 % overall, save +175 % CONFIRMED to the second decimal n/a
§3 — "32 hardcoded user-visible strings" COUNT REFUTED (all 32 literals exist; ~15 are translation defects) n/a

CONFIRMED: 7 · CONFIRMED-WITH-CORRECTION: 2 · REFUTED: 2 (S8-F8's severity, and the 32-string headline). Findings I contributed: 6.


1. The claims I re-derived, and what they measured

All from proof/01_findings/S8_refute/probe_s8_refute.txt (8 probes, EXIT_CODE=0, run on the copy).

Key parity — CONFIRMED, and extended

FR_CHROME_KEY_COUNT=37      EN_CHROME_KEY_COUNT=37
ONLY_IN_FR={}               ONLY_IN_EN={}
FR_TONE_KEY_COUNT=12        EN_TONE_KEY_COUNT=12
TONE_ONLY_IN_FR={}          TONE_ONLY_IN_EN={}
C.tones=[Chirp, Coin, Fanfare, Pop, Cascade, Bowl, Bell, Beep, Chime, Ping, Buzz, Marimba]  COUNT=12
TONES_UNLABELLED_FR=[]      TONES_UNLABELLED_EN=[]

S8's parity figures are exact. I added two checks S8 did not run, both clean: no key in either map has an empty or whitespace-only value (FR_EMPTY_VALUES=[], EN_EMPTY_VALUES=[]), and exactly one key carries an identical string in both languages — KEYS_WITH_IDENTICAL_FR_AND_EN=[volumeLabel], where Volume is genuinely the same word in French. This scoped area is clean and the confirmation is independent.

Text expansion — CONFIRMED to the second decimal

EN_TOTAL_CHARS=887  FR_TOTAL_CHARS=1063
OVERALL_GROWTH_PCT=19.84
1 | save    | 4  | 11 | 175.0 | "Save"       | "Enregistrer"
2 | addStep | 10 | 19 | 90.0  | "+ Add step" | "+ Ajouter une étape"
3 | new     | 5  | 9  | 80.0  | "+ New"      | "+ Nouveau"

887 → 1,063 characters is +19.84 %, which S8 reported as 19.8 %. save 4 → 11 is +175 %. Every one of the ten ranked rows in S8 §5 reproduces, in the same order, with the same figures. I also measured the tone labels, which S8 dismissed without a number: TONE_EN_TOTAL=60 TONE_FR_TOTAL=66 TONE_GROWTH_PCT=10.00 — its judgement that they are not a stress candidate is correct.

The v0.4.7 English regression — CONFIRMED against git history

This was the claim most likely to be wrong, because a regression that never happened would be a serious error. It happened. git show fab7c0a -- lib/engine/store.dart (commit fab7c0a06849125e2e654c4d925016a9ed9bffea, "v0.4.7 : on arrete d'ecrire des phrases a la place du cuisinier", 25 Jul 2026) removes the hand-written seed phrases:

-      d('Mozzarella sticks', 135, 'fritteuse', 'The mozzarella sticks are ready'),
-      d('Fries', 260, 'fritteuse', 'The fries are ready'),
...
-          phrase: 'The chicken is ready',

and adds repairGeneratedPhrases + _seededPhrases, which clear exactly those strings on an existing tablet so readyPhrase regenerates them. I replayed a genuine pre-v0.4.7 tablet (R-PROBE-4):

MIGRATION_CLEARED=7 of 7
  BEFORE="The mozzarella sticks are ready"  AFTER="The mozzarella sticks is ready"  CHANGED=true
  BEFORE="The fries are ready"              AFTER="The fries is ready"              CHANGED=true
  BEFORE="The chicken is ready"             AFTER="The cook chicken is ready"       CHANGED=true
COUNT_OF_SEEDED_TIMERS_WHOSE_ENGLISH_ANNOUNCEMENT_CHANGED=3

Three of seven. S8 said three of seven. CONFIRMED.

French first launch — CONFIRMED

R-PROBE-5, a genuine fresh install with deviceLang: 'fr':

SEEDED=true  CHROME_LANG_AFTER_FRENCH_FIRST_LAUNCH="fr"   TTS_LOCALE=fr-FR
TILE="FRIES"        PHRASE_STORED=""  SPOKEN="Fries est prêt"
TILE="MELT CHEESE"  PHRASE_STORED=""  SPOKEN="Melt cheese est prêt"
TILE="COOK CHICKEN" STEPS=[Cook, Flip, Cook]  SPOKEN="Cook chicken est prêt"

French chrome, French voice locale, English board, English step names. CONFIRMED.

Missing key — mechanism CONFIRMED, severity REFUTED

See finding S8-R1 below. The lookup does return the key (call(missingKeyXyz)="missingKeyXyz", FR_MISSING_FALLS_BACK_TO_EN=false), and I proved it reaches the screen. But S8's reason for rating it LOW does not hold.


2. Findings the stream MISSED

S8-R1 — A key deleted from both locale maps renders raw on screen and the whole suite stays green

  • Severity: MEDIUM (S8 rated the same defect class LOW)
  • Location: lib/i18n.dart:143; guard under test at test/i18n_defaults_test.dart:38-45; render site lib/ui/modals.dart:683 (valid at 03a176e)
  • What is wrong: S8-F8 justifies LOW severity with "parity is perfect and test/i18n_defaults_test.dart:38-45 keeps it that way, so no key can go missing without the suite going red". That is false. The parity test compares the FR key set against the EN key set. Deleting a key from both maps — which is what a careless refactor, a merge, or a bad rebase actually does — leaves the two sets equal, so the parity test passes, and nothing else in the suite asserts that any particular key exists. The identifier then renders verbatim in the interface.
  • Evidence: 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). - Two things this changes about S8-F8. First, the rendered output is not always the camelCase identifier: namePh appears verbatim, but save is drawn as SAVE, because _modalBtn upper-cases. For a French operator that is silently the wrong language rather than a visible fault — worse than the loud failure S8 described. Second, the suite does not protect the key set at all, so the "no key can go missing" premise is gone and the severity floor rises with it. - Why it matters for a restaurant kitchen: the untranslated string class is the one this app is most exposed to, and the only test that looks at translations cannot see it. A cook mid-service reads journalHint or an English SAVE, and CI is green. - Proposed fix: 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 the fix: apply journalhint_mutation.patch and re-run the suite. Today: 123 pass. After (b): exactly the new key-set test fails.

S8-R2 — The editor shows the operator the ungrammatical French phrase, live, next to a correct one

  • Severity: MEDIUM
  • Location: lib/ui/modals.dart:235-242, wired at :337; the correct string is lib/i18n.dart:58 (valid at 03a176e)
  • What is wrong: S8-F2 treats the readyPhrase grammar defect as something the operator hears. It is also something the operator reads, in the editor, before anything is saved. The announcement field's placeholder is live:
  /// What the timer will SAY if the field is left empty — shown as the field's
  /// placeholder, live, from the name being typed. The default is no longer
  /// silently written into the timer on save, so the operator has to be able
  /// to SEE it: type "Frites" and the greyed line reads "Frites est prêt".
  String get _voiceHint {
    final n = _name.text.trim();
    return n.isEmpty ? tr('voicePh') : widget.i18n.readyPhrase(n);
  }

With the name field empty the same placeholder shows voicePh, which in French is 'Les frites sont prêtes' (lib/i18n.dart:58) — correct plural, correct agreement. Type Frites into the name field and the identical placeholder becomes Frites est prêt. One field, one screen, two mutually contradictory French sentences about the same dish. The doc comment at :238 writes the ungrammatical output down as the intended behaviour, so nothing flags it. - Evidence: 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). - Why it matters for a restaurant kitchen: this is the first screen a new cook is walked through. The app writes the correct French, then replaces it with broken French as soon as the cook types. Whatever fix S8-F2 chooses must be applied here too, or the editor will keep advertising the old wording. - Proposed fix: 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 the fix: 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-R3 — The migration's own comment states an invariant the migration violates

  • Severity: LOW
  • Location: lib/engine/store.dart:213-214 (valid at 03a176e)
  • What is wrong: the doc comment on repairGeneratedPhrases reads /// It changes NOTHING about what the app says today (the same words are regenerated); it only lets the announcement follow the language from now on. For three of the seven timers the migration exists to serve, that is false. The same sentence appears in the v0.4.7 commit message («Ne change rien a ce que l'app dit aujourd'hui (memes mots regeneres)»), so the claim was believed, written into the history, and shipped into the source, and nothing tests it.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the comment is what the next person reads before touching the only migration in the app. It tells them the migration is output-neutral. It is not, and believing it is how the pilot tablet's English regression stayed invisible.
  • Proposed fix: 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 the fix: 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-R4 — S8-F6's evidence quotes a class that does not exist in the codebase, and its fix targets the wrong construct

  • Severity: MEDIUM (this is a correction to a finding, and it changes the fix)
  • Location: lib/alarm_backstop.dart:44-59 (valid at 03a176e)
  • What is wrong: S8-F6 presents this as a verbatim quote from lib/alarm_backstop.dart:46-49:
static const _channel = AndroidNotificationChannel(
  'cadence-alarms',
  'Timer alarms',
  description: 'Rings when a timer expires while the app is not on screen',

The code at that location is a different type with a different parameter name, and starts two lines earlier:

  static const AndroidNotificationDetails _channel =
      AndroidNotificationDetails(
    'cadence-alarms',
    'Timer alarms',
    channelDescription:
        'Rings when a timer expires while the app is not on screen',

AndroidNotificationChannel appears nowhere in lib/grep -rn 'AndroidNotificationChannel\| createNotificationChannel' lib/ exits 1. S8-F6 further states "The channel is created once at lib/alarm_backstop.dart:78-80 inside init()"; lines 76-82 are requestNotificationsPermission(), requestExactAlarmsPermission() and a Journal.log. No channel is created there or anywhere — flutter_local_notifications creates it implicitly from the details on the first notification. - Evidence: 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. - Why it matters for a restaurant kitchen: the finding's conclusion survives — Timer alarms and Rings when a timer expires while the app is not on screen are the strings Android shows in Settings → Notifications, they are hardcoded English, and they are the recovery screen the French banner «⚠️ Alarme de secours indisponible» sends a chef to. But S8-F6's fix, "construct _channel inside init() from I18n(store.lang)", would not work as written: _channel is an AndroidNotificationDetails consumed through static const NotificationDetails _details = NotificationDetails(android: _channel) (:60-61), which every show/zonedSchedule call passes. Making it language-aware means dropping both consts and threading an I18n through Backstop, not adding one construction in init(). - Proposed fix: 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 the fix: 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-R5 — Three wrong file:line citations and five wrong line counts, all contradicting the shared code map

  • Severity: LOW
  • Location: S8-F5 "Location"; S8 §8 coverage manifest (valid at 03a176e)
  • What is wrong: AGENT_RULES lists "A finding without a verifiable file:line" as a rejection criterion, and instructs every stream to use research/00_code_map.md rather than re-derive it. S8 cites lines that do not contain what it says they contain, and reports line counts that contradict the code map it was told to use.
S8 says Measured at 03a176e Code map says
android:label="Cadence" at AndroidManifest.xml:22 :19 (:22 is <activity)
AndroidManifest.xml — 74 lines 79 79 (00_code_map.md:1644)
ios/Runner/Info.plist — 78 lines 70 70 (:1647)
web/index.html — 38 lines 46 46 (:1649)
web/manifest.json — 33 lines 35 35 (:1650)
pubspec.yaml — 69 lines 68 68 (:1641)
lib/alarm_backstop.dart:46-49 for the channel :44-59
channel "created once at :78-80" nothing is created there
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: it does not — it matters for whether a Phase-4 fix agent can act on the report. S8-F5 is the rename inventory, a 22-item checklist where items 3 and 7 are irreversible after first publish. A checklist whose first entry points at the wrong line is the one artifact that must be exact.
  • Proposed fix: 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 the fix: re-run wc -l over the manifest's file column and diff against the table.

S8-R6 — "32 hardcoded user-visible strings" counts glyphs, operator text and unreachable fallbacks

  • Severity: LOW
  • Location: S8 §3 and the summary table (valid at 03a176e)
  • What is wrong: I opened all 39 cited sites. Every literal S8 lists is present, at the line it gives — the inventory is accurate as a list of hardcoded literals, and I found no fabricated row. But the summary table presents the number as "Hardcoded user-visible strings bypassing i18n.dart32 distinct strings", and that headline over-states the defect by roughly half. Sorted by what a translator would actually have to do:

  • Language-neutral, nothing to translate (13 rows): '⚙', ':' ×3, '◷', '+'/'−'/'10', '✕', '🗑', '▲'/'▼', '?', '#N', 'CADENCE' (a proper noun), '⏰ $name' ×2 and '${t.name.toUpperCase()} #${widget.batchNo}' (the operator's own text), and the language self-names '🇬🇧 English'/'🇫🇷 Français' which S8 itself marks "correct by design".

  • Coincidentally correct in both languages (2 rows): 'min'MIN, 'sec'SEC, which S8 marks "no visible defect today".
  • Reachable only from malformed persisted JSON (2 rows): lib/engine/models.dart:22 'Step' and :67 'Timer' are fromJson fallbacks; they need a stored record with a missing name, which the app never writes.
  • Genuine untranslated English on a normal screen (about 15 rows): MaterialApp.title, ' — Kitchen Timer', '✎ EDIT', 'Phase', 'Step' ×2, 'Sear'/'Rest', 'Timer', '50 %', 'Timer alarms' + its description, the ten seed literals, '[lot N]', and the two share-sheet strings.
  • Evidence: 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):
                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. - Why it matters for a restaurant kitchen: the number is going into a translation-scope decision. Quoting 32 when 15 need work inflates the estimate by more than 2×, and quoting 32 as "user-visible" invites a reviewer to check a glyph and conclude the inventory is padded. - Proposed fix: split the table into the three classes above and headline the middle number. - How to prove the fix: the classification is in this finding; it needs no test, only the edit.


3. What I attacked and could not break

  • Key parity. Enumerated both maps in both directions, plus C.tones, plus empty-value and identical-value checks S8 never ran. Zero gaps. The confirmation is independent and stands.
  • The 19.8 % / +175 % arithmetic. Recomputed from the maps: 887 → 1,063 = +19.84 %; save 4 → 11 = +175.0 %. All ten ranked rows reproduce in the same order.
  • The v0.4.7 regression. Checked against git show fab7c0a, then replayed on a simulated pre-v0.4.7 tablet. Three of seven, exactly as claimed.
  • The French-first-launch claim. Reproduced end to end, including TTS_LOCALE=fr-FR against an all-English board.
  • seedLangFor. Ran ten inputs including 'fra' and 'FR', which S8 did not test. No false positive: the only ISO codes beginning fr are French, so startsWith('fr') is sound.
  • S8-F1. grep -nE 'flutter_localizations|intl|generate:' pubspec.yaml exits 1, and lib/main.dart (58 lines, read in full) constructs MaterialApp at :43-56 with no localizationsDelegates, supportedLocales or locale. Confirmed.
  • S8-F7. fmtTime(3600.0)="60:00", fmtTime(10800.0)="180:00" (R-PROBE-8) against the editor ceiling min < 180 ? min + 1 : 180 at lib/ui/modals.dart:428. The clock at lib/ui/header.dart:145 is now.hour.toString().padLeft(2, '0') with no locale consulted. The '50 %' at :637 sits above 'Minimum 15%' at lib/i18n.dart:115. All three sub-claims hold.
  • Whether the two mutations were reverted. After the last mutation run, git status --porcelain on the copy returned empty (COPY_RECREATED_CLEAN=yes on a fresh copy afterwards), and the pinned repo is proven clean in pinned_repo_untouched.txt.

4. Coverage manifest — every file in S8's scope

File Lines (measured) What I checked in it
lib/i18n.dart 167 Read in full. Re-enumerated both maps and C.tones (R-PROBE-1). Forced missing-key and five locale values through call/toneLabel/ttsLocale (R-PROBE-2). Ran readyPhrase over 12 names in both languages (R-PROBE-3). Mutated twice — deleted save+namePh, then journalHint, from both maps — and measured what reached the screen and what the suite noticed. Confirmed :143's ! cannot throw. Found voicePh (:58) contradicts readyPhrase (S8-R2).
lib/main.dart 58 Read in full. MaterialApp at :43-56: no localizationsDelegates, no supportedLocales, no locale. title: at :44 verified verbatim. MediaQuery.withNoTextScaling at :48 confirmed.
lib/engine/store.dart 354 Read :180-354 in full. Replayed the v0.4.7 migration on a simulated pre-v0.4.7 tablet (R-PROBE-4). Ran seedLangFor over 10 inputs and Store.lang over 3 stored values plus the no-seed-flag path (R-PROBE-6). Ran a genuine French fresh install (R-PROBE-5). Compared _seededPhrases (:187-195) against git show fab7c0a. Found the false comment at :213-214 (S8-R3).
lib/ui/modals.dart 746 Opened every cited line (:276,277,312,348,364,373,395,406,428,436,482,624,626,637,707,709). Read :235-242 (live announcement placeholder) and :262-290 (chain-mode defaults) in context. Confirmed _voiceHint is wired at :337, and journalHint renders untransformed at :683. Pumped the real editor twice, control and mutated.
lib/ui/header.dart 215 Opened :67,79,97,146. Read the clock builder :141-148 — 24-hour, hand-built, no MediaQuery.alwaysUse24HourFormat.
lib/ui/tile.dart 819 Opened :402,552,602,611,619. Confirmed '✎ EDIT' is a literal, and that :602/:611 are sign: glyphs rather than translatable text.
lib/ui/theme.dart 82 Read :75-82. Ran fmtTime/fmtUp over 9 inputs including the 180-minute editor ceiling (R-PROBE-8).
lib/ui/home.dart 722 Opened :638 and read :700-722 (_empty(), emptyTitle.toUpperCase() at :711). Read :76-84 to confirm the migration call order seedIfFreshrepairGeneratedPhrasesmigrateZoneSounds, and that i18n is bound once at :84.
lib/alarm_backstop.dart 279 Read :36-100. Established the construct is AndroidNotificationDetails (:44-59), not AndroidNotificationChannel; that _details at :60-61 is what call sites pass; that nothing at :76-82 creates a channel; and that AndroidNotificationChannel is absent from all of lib/ (S8-R4). Opened :186,259.
lib/engine/engine.dart 432 Opened :135,137,365. Confirmed '?' is an unknown-parent fallback and '[lot N]' is a French token in an English journal.
lib/engine/models.dart 160 Opened :22,67. Confirmed both are fromJson fallbacks reachable only from a stored record with a missing name — reclassified in S8-R6.
lib/journal.dart 250 Read :60-80,108-135,160-240. Confirmed the export surface is French-only and unrouted through I18n; _describeDevice at :114-130.
lib/diagnostics.dart 54 Read in full. Journal.log sites at :37,44 are French, unrouted. No user-visible widget text.
lib/audio/voice.dart 204 Checked setLocale/init take i18n.ttsLocale; confirmed it is the only locale string crossing to native, so TTS_LOCALE=fr-FR in R-PROBE-5 is what the tablet actually speaks.
lib/audio/audio.dart 116 Checked for user-visible strings: none. AssetSource at :73 is the only source constructed.
lib/audio/alarm_volume.dart 68 Checked for user-visible strings: none.
lib/ui/grid_layout.dart 109 Checked for user-visible strings: none. Pure geometry.
lib/ui/logo.dart 18 Checked: one Image.asset, no text.
test/i18n_defaults_test.dart 117 Read in full. Ran it under two mutations. Established that its parity assertion compares FR and EN key sets and therefore cannot see a key deleted from both — the basis of S8-R1.
android/app/src/main/AndroidManifest.xml 79 (S8 said 74) Read in full. android:label="Cadence" at :19, not :22. <application> spans :18-21. No @string/ lookup anywhere, so there is no localisable app name.
android/app/src/main/res/values*/ 2 dirs Listed res/: values/ and values-night/ only. No strings.xml, no language qualifier. Confirmed.
android/app/build.gradle.kts namespace and applicationId dev.sergemio.cadence (:8,21). Confirmed for the rename inventory.
ios/Runner/Info.plist 70 (S8 said 78) Read in full. CFBundleDisplayName = Cadence; CFBundleName = cadence; CFBundleDevelopmentRegion = $(DEVELOPMENT_LANGUAGE); no CFBundleLocalizations. The five-name claim holds.
ios/Runner.xcodeproj/project.pbxproj developmentRegion = en (:196), knownRegions (:198). One language declared.
ios/Runner/*.lproj 1 dir Base.lproj only (LaunchScreen.storyboard, Main.storyboard). No localised bundle.
web/manifest.json 35 (S8 said 33) "name"/"short_name" = cadence (:2,3), "description": "A new Flutter project." (:8). Stock template.
web/index.html 46 (S8 said 38) <title>cadence</title> (:32), apple-mobile-web-app-title = cadence (:26), description (:21). Stock template.
pubspec.yaml 68 (S8 said 69) No flutter_localizations, no intl, no generate: — grep exits 1.

Files in S8's scope I did not open: none.

Stream S9: finding and refutation

S9 — Platform configuration (Android and iOS)findings/S9_platform_config.md · raw .md

S9 — Platform configuration (Android and iOS)

Stream: S9. Subject: the app repository at pinned commit 03a176e72ef0075eec86b8915cbe6e93042a3b9d. Version 0.4.12+18.

Scope: everything under android/ and ios/ that is configuration rather than code, plus the platform-facing declarations in pubspec.yaml and web/manifest.json. Stream S3 owns the runtime behaviour of the native bridges and the alarm backstop; this stream owns the declarations — permissions, SDK levels, signing, capabilities, identifiers, icons, shrink configuration. Where a permission's justification depends on runtime behaviour, S3's territory is cited and only the declaration is judged here.

iOS caveat, applied to every iOS finding below. This machine has Command Line Tools only — no Xcode, no CocoaPods (proof/00_baseline/doctor.txt). Every iOS finding in this file is static analysis of declaration files, never runtime-verified. No iOS finding claims otherwise.

Raw evidence for this stream:

Artifact Path
37 official-source captures (headless Chromium, never WebFetch) proof/01_findings/S9/captures/
Capture index (URL, HTTP status, UTC retrieval time per file) proof/01_findings/S9/captures/INDEX.json
R4 pointer note proof/03_market/captures/S9_POINTER.md
flutter build appbundle --release proof/01_findings/S9/build_appbundle_release.txt
apksigner + keytool on the release APK and AAB proof/01_findings/S9/apk_aab_signing.txt

Every store-policy claim below carries its source URL and a retrieval date of 2026-08-04 (exact UTC timestamps are in each capture's header line and in INDEX.json).


Findings

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

  • Severity: BLOCKER
  • Location: android/app/build.gradle.kts:30-36 (valid at 03a176e)
  • What is wrong: The release build type is wired to the debug signing config, so every release artifact the project can currently produce is signed with the machine-local, auto-generated Android debug certificate. This is not merely a "not done yet" placeholder: it is an active misconfiguration that produces artifacts which look shippable (flutter build apk --release and flutter build appbundle --release both exit 0) but which Google Play will refuse. The debug keystore is also regenerated per machine and per debug.keystore deletion, so it cannot serve as a durable upload key even if Play accepted it — losing it means the app can never be updated, because Play requires every subsequent upload to use the same certificate.
  • Evidence:

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.

  • Why it matters for a restaurant kitchen: Nothing reaches a kitchen. This single line is the difference between an app that exists and an app that can be installed by a restaurant. It is also a trap: the build is green, the APK installs on a test device, and the failure only surfaces at the Play Console upload step — the last moment before launch.
  • Proposed fix: 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 the fix: 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 — The iOS App Store icon is the byte-identical Flutter placeholder logo; flutter_launcher_icons was configured to skip iOS

  • Severity: BLOCKER
  • Location: pubspec.yaml:35 and ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png (valid at 03a176e)
  • What is wrong: pubspec.yaml runs the launcher-icon generator for Android only (ios: false), so the iOS asset catalogue still holds the stock template artwork committed in the initial Flutter port. The 1024×1024 marketing icon — the one that appears on the App Store product page — is byte-for-byte the Flutter logo. Android, by contrast, has the real Cadence mark. This is not a cosmetic gap: the App Store marketing icon is submission metadata, and shipping a framework's logo as the product icon is both placeholder content and use of artwork the developer has no rights to.
  • Evidence:

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 […]

  • Why it matters for a restaurant kitchen: A chef browsing the App Store sees Google's Flutter logo where the Cadence brand should be. It reads as an unfinished side project, which is fatal for a paid tool a restaurant is being asked to trust with service timing — and it will not survive review in the first place.
  • Proposed fix: 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.
  • How to prove the fix: 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.

S9-F03 — UIBackgroundModes is absent from Info.plist, so the iOS build cannot sound an alarm once the app leaves the screen

  • Severity: BLOCKER
  • Location: ios/Runner/Info.plist:4-69 — the key is absent from the entire top-level <dict> (valid at 03a176e)
  • What is wrong: The whole product promise is that a timer rings. On iOS an app that is not in the foreground is suspended, and its audio stops, unless it declares the audio background mode. Info.plist declares no UIBackgroundModes array at all. AppDelegate.swift sets the .playback audio-session category, which solves a different problem — it makes sound audible when the physical silent switch is on — and does nothing about suspension. The file's own header comment claims .playback is "the closest analogue of USAGE_ALARM", which is true for the mute switch and false for backgrounding. The result is a declared capability gap: the in-app alarm cannot fire while backgrounded, and (see S9-F07) the notification backstop that would cover for it is not configured on iOS either. Both iOS paths to a ringing alarm are therefore closed at the declaration level.
  • Evidence:

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. - Why it matters for a restaurant kitchen: A cook starts a 12-minute timer, then swipes to the ordering app or the tablet's screen locks. On iOS the Cadence process suspends and nothing rings. The dish burns and no one is warned. This is the exact failure the whole backstop architecture exists to prevent, reintroduced by one missing plist key. - Proposed fix: Add to ios/Runner/Info.plist, inside the top-level <dict>:

xml <key>UIBackgroundModes</key> <array> <string>audio</string> </array>

This is compliance plumbing for an already-shipped capability, not a new feature (R6): the app already plays alarm audio; the declaration makes the existing behaviour survive backgrounding. - How to prove the fix: 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.


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

  • Severity: BLOCKER
  • Location: ios/Runner.xcodeproj/project.pbxproj:349, :469, :526 (valid at 03a176e)
  • What is wrong: The Runner target carries no DEVELOPMENT_TEAM, no PROVISIONING_PROFILE_SPECIFIER, no CODE_SIGN_STYLE, and no CODE_SIGN_ENTITLEMENTS. The only signing setting is the stock Flutter template's CODE_SIGN_IDENTITY[sdk=iphoneos*] = "iPhone Developer", which names an identity class rather than a team, and which has been the legacy spelling since Xcode 11 renamed it "Apple Development". No .entitlements file exists anywhere in the repository. Separately, Apple's current floor for App Store Connect uploads is Xcode 26 with an iOS 26 SDK, and this machine has Command Line Tools only. A first iOS build is therefore blocked on two independent things: an Apple Developer Program team identifier wired into the project, and an Xcode installation.
  • Evidence:

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.

  • Why it matters for a restaurant kitchen: There is no iOS product. Half the target market runs iPads on the pass, and today not a single one of them can install Cadence — not through the store, not through TestFlight, not even ad-hoc.
  • Proposed fix: 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 the fix: 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.

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

  • Severity: HIGH
  • Location: android/app/src/main/AndroidManifest.xml:16 and lib/alarm_backstop.dart:53 (valid at 03a176e)
  • What is wrong: The manifest declares USE_FULL_SCREEN_INTENT and the backstop notification actually sets fullScreenIntent: true, so the declaration is genuinely used. The problem is the grant path. For apps targeting Android 14 or higher — Cadence targets 36 — this is a special app access permission that Play auto-grants only to apps whose core function is setting an alarm or receiving calls, it requires a Play Console declaration form, and it is subject to review. Note the asymmetry that makes this a real risk rather than a formality: the exact-alarm policy explicitly names "an alarm or timer app", while the full-screen-intent auto-grant list says only "setting an alarm" and does not name timers. If a reviewer reads a kitchen timer as outside that list, the permission is not auto-granted, and the app must then ask the user for it. Cadence never does: Backstop.init requests notifications and exact alarms and stops there. There is no requestFullScreenIntentPermission() call and no fallback, so a declined auto-grant degrades silently to a notification that never takes over the screen.
  • Evidence:

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 May 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.

  • Why it matters for a restaurant kitchen: The full-screen intent is what turns a backgrounded alarm into something a cook with wet hands and no free attention actually notices — it takes over the tablet instead of sliding a banner past. If Play does not auto-grant it and the app never asks, the alarm quietly demotes to a banner on every device, and nobody finds out until a service goes wrong.
  • Proposed fix: 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 the fix: 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

  • Severity: HIGH
  • Location: android/app/src/main/AndroidManifest.xml:8 (declared) — ACCESS_NOTIFICATION_POLICY absent from lines 2-17; consumed at android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:57 (valid at 03a176e)
  • What is wrong: The app's entire loudness guarantee is that the Settings slider writes straight onto STREAM_ALARM, floored at 15%. That write is AudioManager.setStreamVolume, which throws SecurityException when the change would cross a Do Not Disturb boundary and the caller has not been granted notification policy access. MODIFY_AUDIO_SETTINGS does not confer that access; ACCESS_NOTIFICATION_POLICY is the marker permission that lets an app request it, and it is not declared. The call site wraps the write in try { … } catch (_: Exception), so on a tablet in Do Not Disturb the volume write fails, nothing is logged, no banner appears, and the operator believes the slider took effect.
  • Evidence:

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

  • Why it matters for a restaurant kitchen: Kitchen tablets sit in Do Not Disturb precisely because nobody wants Slack buzzing during service. On exactly those devices the alarm-volume floor stops being enforced, silently. The operator sets the slider, sees it move, and the alarm stream stays wherever the last person left it — possibly at zero.
  • Proposed fix: 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 the fix: 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.

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

  • Severity: HIGH
  • Location: ios/Runner.xcodeproj/project.pbxproj:PBXResourcesBuildPhase (lines within the 97C146EC1CF9000F007C117D block) and lib/alarm_backstop.dart:72-75 (valid at 03a176e)
  • What is wrong: On Android the backstop notification plays a raw resource, RawResourceAndroidNotificationSound('cadence_alarm'), backed by android/app/src/main/res/raw/cadence_alarm.wav. iOS has no equivalent: a local-notification sound must be a file in the app bundle root, referenced by filename through Darwin notification details. The Runner target's resources build phase bundles exactly four items — the two storyboards, AppFrameworkInfo.plist, and the asset catalogue — and no audio file. Compounding it, the plugin is initialised with InitializationSettings(android: …) and no iOS:/macOS: member, so on iOS the plugin is configured with no Darwin settings at all. The declaration side is therefore empty on both counts. S3 owns the runtime consequences; what is judged here is that the bundle declares no iOS alarm sound and the initialisation declares no Darwin configuration.
  • Evidence:

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. - Why it matters for a restaurant kitchen: Even once S9-F03 and S9-F04 are fixed, the iOS safety net stays mute. The one mechanism designed to survive the app being killed — the OS-level scheduled alarm — has no sound to play and no permission to show itself. - Proposed fix: 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 the fix: 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.


S9-F08 — ITSAppUsesNonExemptEncryption is absent, forcing the export-compliance questionnaire on every single upload

  • Severity: MEDIUM
  • Location: ios/Runner/Info.plist:4-69 — key absent from the top-level <dict> (valid at 03a176e)
  • What is wrong: Cadence uses no encryption beyond what the OS provides — it has no network code at all. Because the key is not declared, App Store Connect cannot infer that, and it interposes the export-compliance questionnaire on every build that is uploaded, forever. This is pure recurring submission friction that one static key removes.
  • Evidence:

$ 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.

  • Why it matters for a restaurant kitchen: Indirect but real: every hotfix release costs an extra manual gate. When a kitchen is waiting on a fix during service, friction in the release path is friction in the fix.
  • Proposed fix: 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 the fix: /usr/libexec/PlistBuddy -c "Print :ITSAppUsesNonExemptEncryption" ios/Runner/Info.plist prints false. Red now (Does Not Exist), green after.


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

  • Severity: MEDIUM
  • Location: repository-wide — no .xcprivacy file exists (valid at 03a176e)
  • What is wrong: The app ships no privacy manifest. The binary verdict, established below rather than assumed, is that App Store Connect will not reject the upload for this today — every component that touches a required-reason API carries its own manifest, and the app's own code touches none. That verdict is fragile and entirely undocumented: it depends on facts nobody has written down, and it inverts the moment a single line of app-authored Swift calls UserDefaults, or a dependency without a manifest starts using one. The defect is the absent manifest plus the absent record of why it is currently safe.
  • Evidence:

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 May 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. - Why it matters for a restaurant kitchen: Nothing today. The risk is a silent future rejection: the first developer who adds an analytics call or a plugin without a manifest turns a green upload red, with no local signal and no note in the repo explaining what changed. - Proposed fix: 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 the fix: 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.


S9-F10 — android:allowBackup and android:dataExtractionRules are undeclared, so the timer run-state is cloud-backed-up and device-transferred by default

  • Severity: MEDIUM
  • Location: android/app/src/main/AndroidManifest.xml:18-21 — the <application> element declares only label, name and icon (valid at 03a176e)
  • What is wrong: With neither attribute declared, the platform default applies: automatic cloud backup and device-to-device transfer are on, and every key the app writes is included. That set includes cadence-run-v1, whose RunEntry.endsAt values are absolute epoch milliseconds. Restoring that blob onto a different device — or onto the same device weeks later — reinstates run entries whose deadlines are arbitrarily far in the past. It also silently ships the on-device journal file under the app's documents directory to Google's servers, which is diagnostic data nobody has decided to export.
  • Evidence:

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). - Why it matters for a restaurant kitchen: A restaurant replaces a broken tablet and restores from backup. The new tablet comes up with a board of timers whose deadlines expired last Tuesday. Whether that ends in a burst of phantom alarms or in silently wrong state depends on reconciliation logic owned by another stream — either way, the declaration is what let unowned state cross devices. - Proposed fix: 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. - How to prove the fix: 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.


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

  • Severity: MEDIUM
  • Location: android/app/src/main/res/raw/keep.xml:1-6, guarding android/app/src/main/res/raw/cadence_alarm.wav (valid at 03a176e)
  • What is wrong: cadence_alarm.wav is the 16th audio asset in the project — it sits outside assets/audio/, is not declared in pubspec.yaml, and is referenced only from Dart via RawResourceAndroidNotificationSound('cadence_alarm'). Because that reference is a runtime string rather than an R.raw symbol, the release resource shrinker cannot see it, and it removed the file outright in the v0.3 build. The current defence is a tools:keep rule. I verified that the rule works today. What does not exist is anything that would catch its removal: no test asserts the resource survives shrinking, so the same outage — an alarm that plays silence precisely when the app process is dead, which is the one case the backstop exists for — is one careless edit away and would ship green.
  • Evidence:

Verbatim, android/app/src/main/res/raw/keep.xml in full, including the incident it records:

```xml

```

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

  • Why it matters for a restaurant kitchen: This resource is the last line of defence — it is what sounds when the Cadence process has been killed by the OS and every in-app mechanism is gone. A regression here is invisible in every test, invisible in the build log, and only discovered by a dish that burned because the tablet stayed quiet.
  • Proposed fix: 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 the fix: 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.

S9-F12 — web/manifest.json and web/index.html are the unmodified Flutter template, describing the app as "A new Flutter project."

  • Severity: MEDIUM
  • Location: web/manifest.json:2-9 and web/index.html:21,26,32 (valid at 03a176e)
  • What is wrong: Every user-visible string in the web target is stock scaffolding: the app name is lowercase cadence, the description is A new Flutter project., the theme and background colours are Flutter's #0175C2 rather than the Cadence palette (C.bg and the #F4EFE4 icon ground), and the orientation is locked to portrait-primary — the opposite of a landscape kitchen board. The <title> and Apple web-app title are lowercase too.
  • Evidence:

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."

  • Why it matters for a restaurant kitchen: If the web build is ever put in front of a restaurant — as a demo, a trial, or a tablet-browser fallback — it introduces itself as "A new Flutter project" in Flutter's brand colours, portrait-locked. It reads as a prototype, and the portrait lock makes the timer board unusable on the landscape screens kitchens actually mount.
  • Proposed fix: 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 the fix: 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.

S9-F13 — Stale TODO above a correct application id invites an irreversible change

  • Severity: LOW
  • Location: android/app/build.gradle.kts:20-21 (valid at 03a176e)
  • What is wrong: The value is fine and needs no change: dev.sergemio.cadence is a real reverse-DNS identifier on a domain the developer controls, it matches the Kotlin package on disk, the Gradle namespace, and the iOS PRODUCT_BUNDLE_IDENTIFIER. The TODO above it is Flutter's template boilerplate that was never deleted, and it is actively misleading: it instructs a future reader to change the one value that must never change after the first publication. The defect is the stale comment, not the id.
  • Evidence:

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.

  • Why it matters for a restaurant kitchen: Only through a future mistake. If someone acts on the TODO after launch, every restaurant that installed Cadence keeps an orphaned app that never updates again, and the store listing — with its reviews and its install base — is stranded.
  • Proposed fix: 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 the fix: 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 — ios/RunnerTests/RunnerTests.swift is an empty stub wired into the shared scheme's test action

  • Severity: LOW
  • Location: ios/RunnerTests/RunnerTests.swift:7-10 (valid at 03a176e)
  • What is wrong: The only iOS test asserts nothing. It is the untouched Flutter template stub, and it is a member of the Runner.xcscheme TestAction, so any future iOS CI reports a passing test suite that exercises zero behaviour. Under R8 a test that passes without exercising the behaviour is a defect.
  • Evidence:

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, consider 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. - Why it matters for a restaurant kitchen: A green iOS test badge that means nothing. Given that AppDelegate.swift carries the verbatim header ⚠️ NOT YET COMPILED — written on Windows, no Xcode available., a passing suite is worse than no suite: it implies coverage of a file that has never been compiled. - Proposed fix: 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 the fix: 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

  • Severity: LOW
  • Location: android/gradle.properties:1 (valid at 03a176e)
  • What is wrong: The Gradle daemon is configured with an 8 GB heap plus a 4 GB metaspace ceiling. It works on this machine, and it is a defensible local tuning choice, but as a committed default it fails or thrashes on any build agent with less RAM, and the failure mode is an opaque OOM during a release build rather than a clear message.
  • Evidence:

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. - Why it matters for a restaurant kitchen: Nothing directly. It matters the first time the release build moves off Serge's machine to any CI runner, at which point the release path breaks in a way that looks like a toolchain problem rather than a config line. - Proposed fix: Reduce to -Xmx4G -XX:MaxMetaspaceSize=1G, which comfortably builds this project, and re-raise only if a measured OOM justifies it. - How to prove the fix: 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

  • Severity: LOW
  • Location: android/app/src/main/AndroidManifest.xml:48-56 (valid at 03a176e)
  • What is wrong: The boot receiver is correctly android:exported="false" — I verified against Apple's Android counterpart documentation that this does not block BOOT_COMPLETED, because system-sent broadcasts are explicitly exempt. Two of the four actions in its filter are not system broadcasts, though: android.intent.action.QUICKBOOT_POWERON and com.htc.intent.action.QUICKBOOT_POWERON are sent by OEM apps that do not run under the system UID, and those cannot reach a non-exported receiver. The two lines are therefore inert on exactly the devices they were added for.
  • Evidence:

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.

  • Why it matters for a restaurant kitchen: Marginal, and only on old HTC-lineage hardware that fast-boots without emitting BOOT_COMPLETED. On such a device the backstop alarms are not re-armed after an overnight power cycle, and the morning's first service runs without the safety net.
  • Proposed fix: Delete the two QUICKBOOT_POWERON action lines, since they cannot fire as declared and their presence implies coverage that does not exist. The alternative — exporting the receiver — widens the attack surface to buy compatibility with hardware no kitchen is buying in 2026, and should not be taken.
  • How to prove the fix: 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.

Areas checked that produced no finding

Reported explicitly, per the rule that a clean area must be shown rather than omitted.

Area Verdict Proof
Play target-API requirement PASS. targetSdk/compileSdk resolve to 36 (Android 16), minSdk 24, confirmed in the packaged release manifest: <uses-sdk android:minSdkVersion="24" android:targetSdkVersion="36" />. Play requires new apps to target API 36 from August 31, 2026 — 27 days after this audit. 36 satisfies it with no work. https://support.google.com/googleplay/android-developer/answer/11926878 retrieved 2026-08-04, captures/play_target_api_requirements.txt:22-24, verbatim: "Starting August 31, 2026: New apps and app updates must target Android 16 (API level 36) or higher to be submitted to Google Play"
App Bundle format PASS. Play has required the AAB for new apps since August 2021. flutter build appbundle --release exits 0 and produces a 51,974,023-byte bundle. Nothing blocks it. proof/01_findings/S9/build_appbundle_release.txt: ✓ Built build/app/outputs/bundle/release/app-release.aab (52.0MB) then EXIT_CODE=0. Requirement: https://developer.android.com/guide/app-bundle retrieved 2026-08-04, captures/android_app_bundle.txt:58
versionCode 18 for a first release PASS. 18 causes no problem. Play's only constraints are that the value has never been used before on this listing and that it stays below 2,100,000,000; a first upload has no prior value to collide with, and every later release must simply exceed 18. https://developer.android.com/studio/publish/versioning retrieved 2026-08-04, captures/android_versioning.txt:102,104, verbatim: "The greatest value Google Play allows for versionCode is 2100000000." / "You can't upload an APK to the Play Store with a versionCode you have already used for a previous version."
android:exported correctness PASS on all three components. MainActivity is exported="true" and must be — it holds the MAIN/LAUNCHER filter. Both flutter_local_notifications receivers are exported="false", which is correct and still receives system broadcasts (see S9-F16 for the one narrow exception). AndroidManifest.xml:24, :46, :48; merged release manifest lines 69, 98, 101
android:debuggable leakage PASS. The attribute appears nowhere in the source manifests and is absent from the merged release manifest, so no debuggable release artifact can be produced. grep -c 'android:debuggable' build/app/intermediates/packaged_manifests/release/processReleaseManifestForPackage/AndroidManifest.xml → 0
<queries> blocks PASS. Two entries, both justified and both used: PROCESS_TEXT (required by the Flutter engine's ProcessTextPlugin) and TTS_SERVICE, which the app's own cadence/tts channel needs for package visibility on Android 11+ since flutter_tts was dropped. Both survive the merge. AndroidManifest.xml:68-78; merged manifest lines 39-52
R8 / code and resource shrinking PASS. The Flutter Gradle plugin enables both for release without any declaration in build.gradle.kts. R8 9.0.32 ran, producing a 75,891-line mapping file and a 1,400-line resource report. build/app/outputs/mapping/release/mapping.txt:1-3; .../resources.txt
Android adaptive icon completeness PASS. All five densities present for both the foreground and the monochrome layer at the correct 108 dp dimensions (108/162/216/324/432 px), plus five legacy mipmap icons at 48/72/96/144/192 px. The monochrome layer is present, so Android 13+ themed icons work — this is commonly missing and is not. sips -g pixelWidth -g pixelHeight across res/drawable-*/ and res/mipmap-*/; res/mipmap-anydpi-v26/ic_launcher.xml:9-13 declares the <monochrome> inset
SCHEDULE_EXACT_ALARM capped at API 32 PASS. Declaring USE_EXACT_ALARM for API 33+ and SCHEDULE_EXACT_ALARM with maxSdkVersion="32" for API 31-32 is exactly the documented pattern, not a redundant double-declaration. https://developer.android.com/develop/background-work/services/alarms/schedule retrieved 2026-08-04, captures/android_schedule_alarms.txt:158, verbatim: "If your app targets Android 13 (API level 33) or higher, you have the option to declare either the SCHEDULE_EXACT_ALARM or the USE_EXACT_ALARM permission."
Gradle wrapper absent from git PASS, not a defect. android/gradlew, gradlew.bat and gradle-wrapper.jar are untracked and gitignored, but the Flutter tool injects them on every build, so a fresh clone builds. git ls-files returns nothing for all three; git check-ignore -v confirms android/.gitignore:1 and :4; flutter_tools/lib/src/android/gradle_utils.dart:239,257injectGradleWrapperIfNeeded(androidDir)
iOS deployment target vs plugin floors PASS. IPHONEOS_DEPLOYMENT_TARGET = 13.0 on all three configurations; the highest floor any resolved plugin declares is also 13.0 (shared_preferences_foundation, audioplayers_darwin, flutter_local_notifications, share_plus, device_info_plus). Exactly satisfied, no headroom needed. project.pbxproj:363, :489, :540; Package.swift/.podspec platform lines across the pub cache
AppFrameworkInfo.plist missing MinimumOSVersion PASS, not a defect. The repo file is byte-equivalent in key set to the Flutter 3.44.8 template, which also omits the key. Not a local omission. Diff against Flutter
iOS usage-description strings PASS — none are required. The app uses no camera, microphone, location, photo, contacts or tracking API. AVAudioSession is set to .playback, which is output-only and needs no microphone permission. grep over ios/Runner/*.swift for covered APIs returns nothing; AppDelegate.swift:145 sets .setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
iPad orientations and UIRequiresFullScreen PASS. All four orientations are declared for iPad, which is what iPad multitasking requires, and UIRequiresFullScreen is correctly absent — Apple deprecated it across iOS/iPadOS 9.0–26.0. Info.plist:62-68; https://developer.apple.com/documentation/bundleresources/information-property-list/uirequiresfullscreen retrieved 2026-08-04, captures/apple_uiinterfaceorientation_ipad_multitasking.txt:115-129, verbatim: "Opting out of iPad multitasking and dynamic resizing is deprecated."
Critical alerts entitlement Judged, not missing-by-error. Critical alerts are the only iOS mechanism that sounds through the mute switch and Do Not Disturb, and they need an entitlement Apple issues case by case. Cadence declares none. For a kitchen alarm this is the difference between reliable and best-effort, but it is a business decision requiring an Apple request, not a repo fix — reported here, listed MISSING in the iOS table, and deliberately not raised to a finding since no code change can supply it. https://developer.apple.com/documentation/usernotifications/unauthorizationoptions/criticalalert retrieved 2026-08-04, captures/apple_criticalalert.txt:139,141, verbatim: "Critical alerts ignore the mute switch and Do Not Disturb; the system plays a critical alert's sound regardless of the device's mute or Do Not Disturb settings." / "Critical alerts require a special entitlement issued by Apple."

Table 1 — Android permissions, one row each

Eight permissions are declared in android/app/src/main/AndroidManifest.xml, all eight surviving into the merged release manifest. Unjustifiable: 0 — every one is backed by code I located. Policy risk: 2 — the two that require a Play Console declaration form and are subject to review.

# Permission Declared Actually used by Justification to a Play reviewer Declaration form / policy review? Policy risk
1 VIBRATE AndroidManifest.xml:2 lib/audio/audio.dart:96 _vibratePattern via the vibration package; HapticFeedback at :103-111 Haptic alarm feedback for a noisy kitchen. Normal permission, granted at install. No None
2 WAKE_LOCK :3 wakelock_plus, lib/main.dart:28 WakelockPlus.enable() A timer board must stay lit through service. Normal permission. No None
3 MODIFY_AUDIO_SETTINGS :8 MainActivity.kt:57 audio.setStreamVolume(AudioManager.STREAM_ALARM, target, 0) The Settings slider owns STREAM_ALARM; no auto-boost, floored at 15%. Normal permission. No None for this permission — but the declaration set is incomplete without ACCESS_NOTIFICATION_POLICYS9-F06
4 POST_NOTIFICATIONS :12 lib/alarm_backstop.dart:79 requestNotificationsPermission() The backstop must show the alarm it fires. Runtime permission, user-granted. No None
5 USE_EXACT_ALARM :13 lib/alarm_backstop.dart zonedSchedule(… AndroidScheduleMode.alarmClock) Qualifies. Play's acceptable-use list names "The app is an alarm or timer app" verbatim — Cadence is a timer app and nothing else. Yes — Play Console declaration required; "Apps that request this restricted permission are subject to review" Low but non-zero. The form must be completed and the review passed. Source: https://support.google.com/googleplay/android-developer/answer/9888170 retrieved 2026-08-04, captures/play_sensitive_permissions.txt:461-468
6 SCHEDULE_EXACT_ALARM (maxSdkVersion="32") :14-15 Same call path, API 31-32 only Correct documented pairing: USE_EXACT_ALARM covers 33+, this covers 31-32. The cap prevents a redundant 33+ declaration. No (the cap keeps it below the policy's API-33 trigger) None
7 USE_FULL_SCREEN_INTENT :16 lib/alarm_backstop.dart:53 fullScreenIntent: true Contested. The auto-grant list is "setting an alarm" / "receiving phone or video calls" — it does not name timers, unlike the exact-alarm policy which does. A kitchen timer firing an alarm should qualify; a reviewer reading the list literally may disagree. Yes — declaration required since 2024-05-31; auto-grant restricted to calling/alarm apps since 2025-01-22 HIGHEST OF THE EIGHT. If not auto-granted the app must ask the user, and it never does, with no degraded path → S9-F05. Source: https://support.google.com/googleplay/android-developer/answer/13392821 retrieved 2026-08-04, captures/play_fgs_fsi_requirements.txt:76
8 RECEIVE_BOOT_COMPLETED :17 ScheduledNotificationBootReceiver, AndroidManifest.xml:48-56 Scheduled alarms must be re-armed after a device restart. Normal permission. No None for the permission. Two of its four filter actions are inert as declared → S9-F16

INTERNET is declared in android/app/src/debug/AndroidManifest.xml:6 and android/app/src/profile/AndroidManifest.xml:6 only, never in main. Correct — it is the Flutter template's debug-tooling permission and does not reach the release manifest. Verified absent from the packaged release manifest.


Table 2 — iOS configuration items, one row each

10 items MISSING. 4 of those are required for submission (marked ▲).

# Item Present / Missing Value or gap Required for submission? Finding
1 PRODUCT_BUNDLE_IDENTIFIER Present dev.sergemio.cadence (project.pbxproj:385, :564, :586) — matches Android Yes
2 CFBundleDisplayName Present Cadence (Info.plist:10) Yes
3 CFBundleShortVersionString / CFBundleVersion Present $(FLUTTER_BUILD_NAME) / $(FLUTTER_BUILD_NUMBER)0.4.12 / 18 (Generated.xcconfig) Yes
4 Supported orientations, iPhone Present Portrait + both landscapes (Info.plist:56-61) Yes
5 Supported orientations, iPad Present All four (Info.plist:62-68) — satisfies iPad multitasking Yes
6 UIRequiresFullScreen Correctly absent Deprecated iOS 9.0–26.0 No — must stay absent
7 App icon set Present but placeholder ▲ 1024×1024 marketing icon is byte-identical to the Flutter logo; flutter_launcher_icons has ios: false Yes S9-F02
8 Launch screen Present LaunchScreen.storyboard + a custom LaunchImage set that differs from the template Yes
9 UIBackgroundModes MISSING ▲ No audio mode; the app cannot sound an alarm once backgrounded Yes — the core function fails on-device during review S9-F03
10 DEVELOPMENT_TEAM / signing identity MISSING ▲ Zero occurrences in the 644-line project file; only the template's "iPhone Developer" Yes — cannot sign, cannot upload S9-F04
11 Xcode 26 / iOS 26 SDK toolchain MISSING ▲ Command Line Tools only, no CocoaPods Yes — mandatory for uploads since 2026-04-28 S9-F04
12 PrivacyInfo.xcprivacy MISSING No .xcprivacy anywhere; coverage currently supplied entirely by the engine and the plugins No — verdict established from evidence, not assumed S9-F09
13 ITSAppUsesNonExemptEncryption MISSING Absent → export-compliance questionnaire on every upload No — friction, not rejection S9-F08
14 .entitlements file / CODE_SIGN_ENTITLEMENTS MISSING No entitlements file exists in the repository No — conditional on item 15 S9-F04
15 Critical-alert entitlement MISSING Needed to sound through the mute switch and Do Not Disturb; issued case by case by Apple No — but the alarm is best-effort without it Judged above
16 iOS notification sound resource MISSING Runner resources build phase bundles four items, none audio No — but the iOS backstop is mute S9-F07
17 DarwinInitializationSettings MISSING InitializationSettings(android: …) only, no iOS member No — but no iOS notification permission is ever requested S9-F07
18 Usage-description strings Correctly absent No camera / microphone / location / photos / tracking API is used No — none required
19 IPHONEOS_DEPLOYMENT_TARGET Present 13.0, equal to the highest plugin floor Yes
20 Scene manifest / SceneDelegate Present UIApplicationSceneManifest (Info.plist:29-49) + SceneDelegate.swift — current Flutter 3.44 template Yes
21 Dependency manager Present Swift Package Manager (FlutterGeneratedPluginSwiftPackage); no Podfile, consistent with SPM mode Yes

Coverage manifest

Every file in the S9 scope, its line count, and what was checked in it. Binary assets are listed with their dimensions or size instead of a line count.

File Lines What I checked
android/app/build.gradle.kts 51 Read in full. namespace (8), compileSdk/ndkVersion delegation (9-10), Java 17 + desugaring (12-17), applicationId and the stale TODO (20-21) → F13, SDK/version delegation (24-27), release signing config (30-36) → F01, Kotlin JVM target (39-43), desugar dependency (46). Confirmed no minifyEnabled/shrinkResources declaration and that the Flutter plugin supplies both.
android/build.gradle.kts 24 Read in full. Repositories (1-6), the relocated build directory (8-17), evaluationDependsOn(":app") (18-20), clean task (22-24). Nothing store-facing; no finding.
android/settings.gradle.kts 26 Read in full. local.properties Flutter SDK resolution (2-9), plugin versions — AGP 9.0.1, Kotlin 2.3.20 (20-24). Verified these match the toolchain baseline. No finding.
android/gradle.properties 6 Read in full. All four properties checked; JVM args → F15. useAndroidX, newDsl, builtInKotlin are template defaults.
android/gradle/wrapper/gradle-wrapper.properties 5 Read in full. Gradle 9.1.0 distribution URL confirmed consistent with the AGP version. No finding.
android/.gitignore 14 Read in full. Confirmed keystore exclusions (key.properties, *.keystore, *.jks) already exist, which the F01 fix depends on; confirmed the wrapper exclusions and proved via flutter_tools that they are safe.
android/app/src/main/AndroidManifest.xml 79 Read in full. All eight permissions individually judged → Table 1, F05, F06, F16. <application> attributes → F10. Activity exported/launchMode/taskAffinity/configChanges (22-30). Both receivers (46-56). flutterEmbedding meta-data (59-61). Both <queries> intents (68-78).
android/app/src/debug/AndroidManifest.xml 7 Read in full. INTERNET confirmed debug-only and proven absent from the merged release manifest. No finding.
android/app/src/profile/AndroidManifest.xml 7 Read in full. Identical to debug; same verification. No finding.
(brief said "all four AndroidManifest.xml files") Only three exist in source. find . -name AndroidManifest.xml -not -path "./.git/*" returns three under android/app/src/ plus generated copies under build/. Recorded so the discrepancy is not read as an omission.
build/…/processReleaseManifestForPackage/AndroidManifest.xml 181 Read in full as the merged-manifest oracle. Confirmed resolved minSdkVersion="24"/targetSdkVersion="36", versionCode="18", all eight permissions surviving, exported values post-merge, absence of android:debuggable and android:allowBackup, and the share_plus provider/receiver injected by the plugin.
android/app/src/main/res/raw/keep.xml 6 Read in full → F11. Cross-checked against the shrinker report line raw:cadence_alarm:2131558400 reachable from keep xml file.
android/app/src/main/res/raw/cadence_alarm.wav 142,928 bytes The 16th audio asset, outside assets/audio/ and undeclared in pubspec.yaml. Verified it is referenced only from lib/alarm_backstop.dart:55, that it survives shrinking, and that it lands in the APK as res/pC.wav at the identical byte length → F11.
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml 14 Read in full. Background/foreground/monochrome layers and the 16% insets. Monochrome layer confirmed present.
android/app/src/main/res/values/colors.xml 3 Read in full. ic_launcher_background = #F4EFE4, matching pubspec.yaml:37.
android/app/src/main/res/values/styles.xml 18 Read in full. LaunchTheme / NormalTheme, light variants. No finding.
android/app/src/main/res/values-night/styles.xml 18 Read in full. Dark variants, structurally parallel. No finding.
android/app/src/main/res/drawable/launch_background.xml 12 Read in full. White pre-v21 splash. No finding.
android/app/src/main/res/drawable-v21/launch_background.xml 12 Read in full. ?android:colorBackground splash. No finding.
android/app/src/main/res/drawable-{m,h,xh,xxh,xxxh}dpi/ic_launcher_foreground.png 5 files Dimensions measured: 108/162/216/324/432 px square. Correct 108 dp adaptive foreground at every density. No finding.
android/app/src/main/res/drawable-{m,h,xh,xxh,xxxh}dpi/ic_launcher_monochrome.png 5 files Dimensions measured: identical set. Themed-icon layer complete. No finding.
android/app/src/main/res/mipmap-{m,h,xh,xxh,xxxh}dpi/ic_launcher.png 5 files Dimensions measured: 48/96/72/144/192 px. Rendered mipmap-xxxhdpi to confirm it is the real Cadence mark, not a placeholder. No finding.
ios/Runner/Info.plist 70 Read in full; enumerated every top-level key. UIBackgroundModes absent → F03; ITSAppUsesNonExemptEncryption absent → F08. Orientations, scene manifest, LSRequiresIPhoneOS, bundle identity keys all checked → Table 2.
ios/Runner.xcodeproj/project.pbxproj 644 Grepped and read the relevant configuration blocks. Signing (349, 397, 414, 429, 469, 526) → F04; DEVELOPMENT_TEAM absent; bundle ids (385, 564, 586); IPHONEOS_DEPLOYMENT_TARGET (363, 489, 540); TARGETED_DEVICE_FAMILY = "1,2"; ENABLE_BITCODE = NO; objectVersion/LastUpgradeCheck; the complete PBXResourcesBuildPhaseF07.
ios/Flutter/Debug.xcconfig 1 Read in full. Single #include "Generated.xcconfig". No finding.
ios/Flutter/Release.xcconfig 1 Read in full. Identical. No finding.
ios/Flutter/AppFrameworkInfo.plist 24 Read in full. Diffed against the Flutter 3.44.8 template — the absent MinimumOSVersion is a template property, not a local omission. No finding.
ios/Flutter/Generated.xcconfig 15 Read in full (untracked, generated). Confirmed FLUTTER_BUILD_NAME=0.4.12 / FLUTTER_BUILD_NUMBER=18 feed the plist substitutions, and noted DART_OBFUSCATION=false, TREE_SHAKE_ICONS=false.
ios/Runner/Base.lproj/Main.storyboard 26 Read in full. Single FlutterViewController scene, referenced by UIMainStoryboardFile and the scene manifest. No finding.
ios/Runner/Base.lproj/LaunchScreen.storyboard 37 Read in full. Centred LaunchImage with centre-X/centre-Y constraints. No finding.
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json 122 Read in full. All 15 declared slots have a backing file present on disk; ios-marketing 1024 slot present. Completeness is fine — the content is the defect → F02.
ios/Runner/Assets.xcassets/AppIcon.appiconset/*.png 15 files MD5-compared against the Flutter SDK template. Icon-App-1024x1024@1x.png is byte-identical to the template; rendered it to confirm it is the Flutter logo → F02.
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json 23 Read in full. Three universal scales declared, all three files present. No finding.
ios/Runner/Assets.xcassets/LaunchImage.imageset/*.png 3 files MD5-compared against the template: all three differ, so the launch image was customised. Contrast with the icons. No finding.
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md 4 Read in full. Stock template guidance. No finding.
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme 119 Read the four action blocks. TestAction includes RunnerTests.xctestF14; Profile/Archive configurations checked.
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata 7 Read in full. Self-reference only. No finding.
ios/Runner.xcworkspace/contents.xcworkspacedata 7 Read in full. Confirmed no Pods.xcodeproj — SPM mode, consistent with the absent Podfile. No finding.
ios/RunnerTests/RunnerTests.swift 12 Read in full → F14.
ios/Runner/SceneDelegate.swift 6 Read in full. Empty FlutterSceneDelegate subclass, matching the Flutter 3.44 template and the scene manifest. No finding.
ios/Runner/Runner-Bridging-Header.h 1 Read in full. GeneratedPluginRegistrant.h import. No finding.
ios/.gitignore 34 Read in full. Confirmed Flutter/Generated.xcconfig, Flutter/ephemeral/ and Pods/ exclusions are the template's, and that no signing material is accidentally trackable. No finding.
pubspec.yaml 68 Read in full. Platform-facing declarations only, per scope: version: 0.4.12+18 (5), the flutter_launcher_icons block with ios: false (33-39) → F02, and the description string used as the F12 fix source (2). Dependency list read only to resolve Darwin plugin versions for F09.
pubspec.lock Read the resolved versions of the nine Darwin-relevant packages, to pin the F09 privacy-manifest analysis to what actually ships.
web/manifest.json 35 Read in full → F12. All eight metadata keys and the four icon entries checked; icon files confirmed present on disk.
web/index.html 46 Read the metadata region (18-34) → F12.

Captures. 37 official-source pages under proof/01_findings/S9/captures/, all fetched with utilities/chrome.py::render_page (headless Chromium) and never WebFetch or requests.get(), per R4. Each file's header records its URL, HTTP status and UTC retrieval timestamp; INDEX.json is the machine-readable index. Four Apple documentation URLs returned HTTP 404 (their required-reason-API category anchors have moved); none of the findings above depends on them, and the F09 verdict is carried by the two Apple pages that did return 200 plus the manifest contents read directly out of the pub cache and the Flutter engine artifact.

Read-only compliance (R10). No file under the app repository was created, edited or deleted, and no git state was changed. git status --porcelain returns empty. The only writes were the AAB build's output under the gitignored build/ tree — the same tree the Phase-0 baseline already populated — and the proof and findings files under Cadence_App_Audit/.

S9 refutation — platform configuration (Android and iOS)agent_reports/S9_refute.md · raw .md

S9 refutation — platform configuration (Android and iOS)

Refuter for stream S9, fresh context, governed by R5. Subject pinned at 03a176e72ef0075eec86b8915cbe6e93042a3b9d, version 0.4.12+18.

Independence. I did not reuse a single one of S9's artifacts. I made my own copy of the repo at the pinned sha under a scratch working copy (rsync excluding build/ and .dart_tool/), produced my own release APK and AAB there, ran apksigner and keytool myself, and re-fetched every policy page myself with utilities/chrome.py::render_page (headless Chromium, never WebFetch, never requests.get()), per R4. The pinned repo was never modified: every mutation happened on the copy (R10), and the copy's tree state is stamped in each proof header.

My raw evidence

Artifact Path
My release APK build in the copy proof/01_findings/S9_refute/build_apk_release_refute.txt
My apksigner verify --print-certs --verbose + shrinker + APK contents proof/01_findings/S9_refute/apk_signing_refute.txt
My AAB build + keytool -printcert -jarfile proof/01_findings/S9_refute/aab_signing_refute.txt
Negative control: build with keep.xml DELETED proof/01_findings/S9_refute/keepxml_negative_control.txt
Restore control: keep.xml restored, rebuild, tree clean proof/01_findings/S9_refute/keepxml_restored_rebuild.txt
Static verifications, part 1 (asset hashes, icon properties) proof/01_findings/S9_refute/static_verifications.txt
Static verifications, part 2 (template diffs, absence counts, privacy chain, tree state) proof/01_findings/S9_refute/static_verifications_part2.txt
20 official-source captures, mine proof/01_findings/S9_refute/captures/ + captures/INDEX.json

Part 1 exits 2 partway through: run_and_record.sh invokes sh, which is POSIX and rejects the process substitution the template diffs need. Part 2 re-runs everything from that point under bash and completes with EXIT_CODE=0. Both files are kept; the failure is visible rather than tidied away.

Every policy claim below carries the URL and the UTC retrieval timestamp recorded in the header line of the named capture file. All captures were taken 2026-08-04.


Part 1 — Verdict on every S9 finding

# S9 title (short) S9 sev Verdict Sev after Source URL for the policy claim
F01 Release build signs with the debug keystore (APK + AAB) BLOCKER CONFIRMED (reproduced on my own build) BLOCKER https://developer.android.com/studio/publish/app-signing
F02 iOS 1024 icon is the Flutter template logo BLOCKER CONFIRMED and strengthened — all 15 icons are the template, not just the 1024 BLOCKER (justification restated) https://developer.apple.com/app-store/review/guidelines/
F03 UIBackgroundModes absent → no backgrounded iOS alarm BLOCKER SPLIT: fact CONFIRMED, proposed fix REFUTED — (merged into F07) https://developer.apple.com/documentation/xcode/configuring-background-execution-modes
F04 No DEVELOPMENT_TEAM; Xcode 26 required since 2026-04-28 BLOCKER CONFIRMED, date verbatim BLOCKER https://developer.apple.com/news/upcoming-requirements/
F05 USE_FULL_SCREEN_INTENT never requested, no degraded path HIGH CONFIRMED HIGH https://support.google.com/googleplay/android-developer/answer/9888170 · https://support.google.com/googleplay/android-developer/answer/13392821
F06 ACCESS_NOTIFICATION_POLICY absent, SecurityException swallowed HIGH CONFIRMED HIGH https://developer.android.com/reference/android/media/AudioManager
F07 No iOS notification sound, plugin initialised Android-only HIGH CONFIRMED — severity RAISED to BLOCKER BLOCKER https://developer.apple.com/documentation/usernotifications/unnotificationsound
F08 ITSAppUsesNonExemptEncryption absent MEDIUM CONFIRMED MEDIUM https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption (S9's capture; my Info.plist key-set diff independently proves the absence)
F09 PrivacyInfo.xcprivacy not required today MEDIUM CONFIRMED — every link re-verified, plus one plugin S9 omitted MEDIUM https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api
F10 allowBackup / dataExtractionRules undeclared MEDIUM CONFIRMED (0 occurrences in my merged manifest) MEDIUM https://developer.android.com/guide/topics/data/autobackup (S9 capture; the absence is proved from my own build)
F11 keep.xml works but has no regression test MEDIUM CONFIRMED with a three-state mutation proof MEDIUM — (measured, no policy claim)
F12 web/manifest.json + index.html are the template MEDIUM CONFIRMED, and extended (see M2) MEDIUM
F13 Stale TODO above a correct application id LOW CONFIRMED LOW https://developer.android.com/studio/build/application-id (S9 capture)
F14 RunnerTests.swift is an empty stub in the shared scheme LOW CONFIRMED LOW
F15 org.gradle.jvmargs demands 12 GB LOW CONFIRMED LOW
F16 Two OEM quick-boot actions inert on a non-exported receiver LOW Facts CONFIRMED, causal claim and proposed fix REFUTED LOW (fix changed) https://developer.android.com/guide/topics/manifest/receiver-element

Counts: 13 CONFIRMED outright, 1 confirmed-and-raised (F07), 2 partially refuted (F03 fix, F16 causal claim + fix). 0 findings refuted in their entirety.

BLOCKERs: 3 of 4 survive as written (F01, F02, F04). F03 does not survive as written; the blocking condition it describes is real but it is F07's, and F07 is raised to BLOCKER in its place. The stream's BLOCKER count is therefore unchanged at 4 — but one of them changes identity, and with it the Phase-4 fix.


Part 2 — The four BLOCKERs, attacked individually

BLOCKER 1 — Debug-signed release, APK and AAB — CONFIRMED

I built from a clean copy of the pinned tree and ran the tools myself. proof/01_findings/S9_refute/apk_signing_refute.txt (TREE_STATE: CLEAN, GIT_HEAD: 03a176e…, REPO: a scratch working copy), verbatim:

Verifies
Verified using v1 scheme (JAR signing): false
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): false
Number of signers: 1
Signer #1 certificate DN: C=US, O=Android, CN=Android Debug
Signer #1 certificate SHA-256 digest: 49d5b0ff27a90c3e017dcd7c04cd979111cce9a8de4a666b2c492e13800e1aae
Signer #1 key algorithm: RSA
Signer #1 key size (bits): 2048

And my own AAB, proof/01_findings/S9_refute/aab_signing_refute.txt, verbatim:

✓ Built build/app/outputs/bundle/release/app-release.aab (52.0MB)
=== keytool on AAB ===
Owner: C=US, O=Android, CN=Android Debug
Issuer: C=US, O=Android, CN=Android Debug
     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

Same certificate fingerprint S9 reported, obtained independently from a fresh copy. The finding is reproducible, not an artefact of one build tree.

Is it an absolute bar or a warning? Google's own developer documentation states it as a fact about its own store, not as advice. From my capture proof/01_findings/S9_refute/captures/android_app_signing.txt:154 (https://developer.android.com/studio/publish/app-signing, retrieved 2026-08-04T10:41Z), 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.

That is an absolute bar for publishing, phrased by Google about Google Play. I did not find a separate Play Console page reproducing the upload-time error string, and I make no claim about one: the sentence above is the official source and it is sufficient. BLOCKER upheld.

One thing S9 did not report and I did: the artefact is signed with v2 only — no v1 JAR signature, no v3. That is correct and not a defect at minSdkVersion 24 (v2 is the floor from API 24), and Play App Signing re-signs bundles anyway. Recorded so the next reader does not chase it.

BLOCKER 2 — iOS icon byte-identical to the Flutter logo — CONFIRMED, and worse than reported

S9 compared against flutter_tools/templates/module/ios/host_app_ephemeral/…. That is the module template. The template a normal flutter create app receives lives in the flutter_template_images pub package, because the SDK's app/ios.tmpl/…/*.png.img.tmpl files are zero-byte stubs (md5 = d41d8cd98f00b204e9800998ecf8427e, the empty-file digest). I compared against the real source, flutter_template_images-5.0.0:

IDENTICAL  Icon-App-1024x1024@1x.png      IDENTICAL  Icon-App-40x40@3x.png
IDENTICAL  Icon-App-20x20@1x.png          IDENTICAL  Icon-App-60x60@2x.png
IDENTICAL  Icon-App-20x20@2x.png          IDENTICAL  Icon-App-60x60@3x.png
IDENTICAL  Icon-App-20x20@3x.png          IDENTICAL  Icon-App-76x76@1x.png
IDENTICAL  Icon-App-29x29@1x.png          IDENTICAL  Icon-App-76x76@2x.png
IDENTICAL  Icon-App-29x29@2x.png          IDENTICAL  Icon-App-83.5x83.5@2x.png
IDENTICAL  Icon-App-29x29@3x.png
IDENTICAL  Icon-App-40x40@1x.png
IDENTICAL  Icon-App-40x40@2x.png

All fifteen app-icon slots are the untouched Flutter template, not only the 1024 marketing icon. Every home-screen, Spotlight, Settings and notification icon on iOS is the Flutter chevron. S9's hash for the 1024 (c785f8932297af4acd5f5ccb7630f01c) is correct — the module template and the flutter_template_images copy of that one file happen to be the same bytes.

Is shipping it a rejection under Apple's stated review guidelines? Yes — it violates written rules, but it is not a mechanical upload rejection, and that distinction matters for Phase 4.

What is written. From my capture captures/appstore_review_guidelines.txt (https://developer.apple.com/app-store/review/guidelines/, retrieved 2026-08-04T10:44Z):

  • Line 153, Guideline 2.1(a) App Completeness, verbatim: "Submissions to App Review, including apps you make available for pre-order, 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. […] We will reject incomplete app bundles and binaries that crash or exhibit obvious technical problems."
  • Line 173, Guideline 2.3.9, verbatim: "You are responsible for securing the rights to use all materials in your app icons, screenshots, and previews, and you should display fictional account information instead of data from a real person."
  • Line 452, Guideline 5.2.1, verbatim: "Don't use protected third-party material such as trademarks, copyrighted works, or patented ideas in your app without permission, and don't include misleading, false, or copycat representations, names, or metadata in your app bundle or developer name."

S9 cited 2.1 and 2.3.9 accurately. It missed 5.2.1, which is the sharper one: the Flutter logo is Google's mark, and it is being used as the product's own icon.

What is not written. There is no automated App Store Connect check that recognises the Flutter logo. The icon is otherwise mechanically valid: I measured it — sips reports pixelWidth: 1024, pixelHeight: 1024, hasAlpha: no, format: png. So the alpha-channel upload rejection (the one mechanical icon check Apple runs) does not fire. Upload will succeed; human review is where this dies.

Verdict: BLOCKER upheld, justification restated. Under R13 it does not "prevent store submission"; it prevents approval, under three written guidelines. That is at least as bad, and the severity stands — but Phase 4 must not be told the upload will bounce, because it will not, and a green upload will be read as a green light.

BLOCKER 3 — UIBackgroundModes absent — fact CONFIRMED, proposed fix REFUTED

The plist fact is confirmed, twice over. ios/Runner/Info.plist is 70 lines and grep -c UIBackgroundModes returns 0. I also diffed the file's complete key set against the Flutter 3.44.8 app template (templates/app/ios.tmpl/Runner/Info.plist.tmpl): the two key sets are identical, with zero differences. The plist has never had a single project-specific key added to it, which independently corroborates F03, F08 and my own M3 below.

The causal claim is confirmed in its outcome. A backgrounded Cadence on iOS cannot ring. From my capture captures/apple_configuring_background_execution_modes.txt (https://developer.apple.com/documentation/xcode/configuring-background-execution-modes, retrieved 2026-08-04T11:52Z), verbatim:

Typically, an app is in a suspended state when it's in the background. However, there are a limited number of background execution modes your app can support that enable it to run when in the background, such as playing audio, receiving location updates, or processing scheduled tasks.

The mechanism and the fix are wrong. On the same page, the audio mode is defined verbatim as:

Audio, AirPlay, and Picture in Picture — audioThe app plays audible content in the background.

The exemption is for an app that is playing audible content. It is not a licence to wake up later. Cadence plays nothing while a timer counts down — the alarm is the event, not the state. So on a 12-minute timer the app is suspended at T+0s the moment the cook swipes away, its Dart timer never fires, and audio in the plist changes nothing about that. Adding the key as S9 proposes would produce a plist that passes S9's own PlistBuddy check, ship, and still not ring.

It would also invite a review problem. Guideline 2.5.4, captures/appstore_review_guidelines.txt:197, verbatim: "Multitasking apps may only use background services for their intended purposes: VoIP, audio playback, location, task completion, local notifications, etc." An app declaring audio while playing no background audio is declaring a service it does not provide.

The true mechanism is a scheduled local notification, which is exactly what S9-F07 says is unconfigured. From captures/apple_scheduling_local_notification.txt (https://developer.apple.com/documentation/usernotifications/scheduling-a-notification-locally-from-your-app, retrieved 2026-08-04T10:47Z), verbatim:

The system handles delivery of notifications based on a time or location that you specify. If the delivery of the notification occurs when your app isn't running or in the background, the system interacts with the user for you.

And Apple names this exact use case. captures/apple_untimeintervalnotificationtrigger.txt (https://developer.apple.com/documentation/usernotifications/untimeintervalnotificationtrigger, retrieved 2026-08-04T10:47Z), verbatim:

Create a UNTimeIntervalNotificationTrigger object when you want to schedule the delivery of a local notification after the number of seconds you specify elapses. You use this type of trigger to implement timers.

The app already calls the cross-platform path that lands there: lib/alarm_backstop.dart:184 await _plugin.zonedSchedule(. What is missing on iOS is the Darwin half of the configuration — DarwinInitializationSettings, DarwinNotificationDetails, and a bundled sound file. That is S9-F07, verbatim its own scope.

Direction for Phase 4, stated as a binary call: implement F07. Do not treat UIBackgroundModes: audio as the fix for a backgrounded alarm. audio has one narrow legitimate use here — keeping an alarm that is already ringing in the foreground audible after the cook swipes away — and it may be added for that reason and that reason only, after F07 lands. Getting this backwards is precisely the failure the brief warned about.

Consequence for severities: F03 as written is not a BLOCKER, because its fix does not unblock anything. F07 is raised HIGH → BLOCKER: under R13 a failure to ring an alarm is a BLOCKER by definition, and F07 is the finding that owns that failure.

BLOCKER 4 — No DEVELOPMENT_TEAM, Xcode 26 required since 2026-04-28 — CONFIRMED

The date and the requirement, from my own capture. proof/01_findings/S9_refute/captures/apple_upcoming_requirements.txt (https://developer.apple.com/news/upcoming-requirements/, retrieved 2026-08-04T10:44:38Z), 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.

S9's date and wording are exact. CONFIRMED.

The project fact, verified independently and more sharply than S9 did. grep -c DEVELOPMENT_TEAM ios/Runner.xcodeproj/project.pbxproj returns 0 across 644 lines, and find . -name "*.entitlements" returns nothing. I then diffed the set of build-setting names in the repo's project file against the Flutter 3.44.8 template (templates/app/ios.tmpl/Runner.xcodeproj/project.pbxproj.tmpl):

32a33
> DEVELOPMENT_TEAM=

DEVELOPMENT_TEAM is the only build setting the template can emit that the repo does not have — and the template emits it conditionally:

{{#hasIosDevelopmentTeam}}
                DEVELOPMENT_TEAM = {{iosDevelopmentTeam}};
{{/hasIosDevelopmentTeam}}

So its absence is not drift, it is proof that no Apple team was ever selected. I also confirmed S9's annotation that the three CODE_SIGN_STYLE = Automatic lines belong to RunnerTests and not to Runner: each sits in a configuration block whose PRODUCT_BUNDLE_IDENTIFIER is dev.sergemio.cadence.RunnerTests (project.pbxproj:394-431). Accurate.

Toolchain: flutter doctor -v on this machine reports the Android SDK at the Android SDK and Java 17.0.20, and the baseline records ✗ Xcode installation is incomplete. Both halves of the block are real. BLOCKER upheld.


Part 3 — The two contrarian verdicts

Contrarian A — PrivacyInfo.xcprivacy is NOT currently required — CONFIRMED. Settled.

S9 called its own verdict "fragile". It is not fragile; it is correct, and I verified every link myself rather than accepting the table. I also closed a gap S9 left.

Step 1 — the rule. From my capture captures/apple_required_reason_api.txt (https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api, retrieved 2026-08-04T11:00Z), the hard rule 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 May 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.

The ownership rule 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.

And the sentence S9 did not quote, which is the one that actually decides the Flutter case, verbatim:

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.

The obligation attaches to the bundle containing the executable that makes the call. This is why lib/journal.dart's f.length() — which bottoms out in stat, a File-timestamp-category API — does not oblige the app: the stat call is issued by the Dart runtime, which lives inside Flutter.framework, and that framework carries its own manifest.

Step 2 — the Flutter engine manifest, read from the artifact, not asserted. Flutter, plutil-converted, verbatim:

    <key>NSPrivacyAccessedAPITypes</key>
    <array>
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>0A2A.1</string>
                <string>C617.1</string>
            </array>
        </dict>
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategorySystemBootTime</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>35F9.1</string>
            </array>
        </dict>
    </array>

Exactly the two categories and three reason codes S9 reported. CONFIRMED.

Step 3 — the plugin set, enumerated from the build, not from prose. S9's table lists eight Darwin components. The build actually links nine iOS plugins. From .flutter-plugins-dependencies, the authoritative list the tool generates:

audioplayers_darwin 6.5.0 · device_info_plus 13.2.0 · flutter_local_notifications 22.1.0 · package_info_plus 10.2.1 · path_provider_foundation 2.6.0 · share_plus 13.3.0 · shared_preferences_foundation 2.5.6 · vibration 3.2.0 · wakelock_plus 1.7.0

package_info_plus 10.2.1 is absent from S9's table. It is pulled in transitively — the dependency record shows wakelock_plus … dependencies= ['package_info_plus']. A chain that omits a link is not a chain, so I closed it: it ships ios/package_info_plus/Sources/package_info_plus/PrivacyInfo.xcprivacy with <key>NSPrivacyAccessedAPITypes</key><array/> — an empty accessed-API array. The verdict survives.

Per-component result, every row read from the pub cache by me:

Darwin component .xcprivacy present? NSPrivacyAccessedAPITypes
Flutter engine 3.44.8 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
device_info_plus 13.2.0 yes empty
vibration 3.2.0 yes empty
wakelock_plus 1.7.0 yes empty
package_info_plus 10.2.1 yes emptythe link S9 omitted
path_provider_foundation 2.6.0 no no native bundle exists — see below
audioplayers_darwin 6.5.0 no zero covered API in its Swift sources — see below

Step 4 — the two components with no manifest, checked in their source.

path_provider_foundation 2.6.0 is genuinely a pure-Dart FFI plugin. Its pubspec.yaml declares only dartPluginClass: PathProviderFoundation under ios: and macos: — there is no pluginClass, and ls on the package shows only lib/, example/, test/, tool/: no ios/, no darwin/, no podspec, no Package.swift. It has no bundle in which a manifest could live. The only Foundation symbol its Dart FFI bindings reference is NSSearchPathForDirectoriesInDomains (7 hits across lib/src/ffi_bindings.g.dart and lib/src/path_provider_foundation_real.dart, and nothing else from any covered category). That symbol appears in none of Apple's five required-reason categories. No obligation, on the plugin or on the app.

audioplayers_darwin 6.5.0 has five Swift files under darwin/audioplayers_darwin/Sources/audioplayers_darwin/. I grepped all of them for the full covered symbol set — creationDate|modificationDate|fileModificationDate|contentModificationDateKey|creationDateKey|getattrlist|getattrlistbulk|fgetattrlist|stat(|fstat|lstat|getattrlistat|NSFileCreationDate|NSFileModificationDate|NSURLContentModificationDateKey|NSURLCreationDateKey|systemUptime|mach_absolute_time|volumeAvailableCapacity|volumeTotalCapacity|systemFreeSize|systemSize|statfs|statvfs|activeInputModes|UserDefaults — and the grep exits 1 with no output. No covered API. No obligation.

Step 5 — the app's own code. grep -nE "UserDefaults|systemUptime|mach_absolute|statfs|volumeAvailableCapacity|modificationDate|creationDate|activeInputModes|stat\(|NSFileCreationDate|fileSystemFreeSize" ios/Runner/*.swift ios/RunnerTests/*.swift exits 1 with no output. The app's Swift is a method-channel adapter and a scene delegate; it touches nothing covered.

Definitive verdict: PrivacyInfo.xcprivacy is NOT required for Cadence at 03a176e. Every required-reason API reached at runtime is reached from a bundle that ships its own manifest declaring it, and the app's own code reaches none.

What Phase 4 must and must not do. Adding an app-level PrivacyInfo.xcprivacy with NSPrivacyTracking = false and three empty arrays, as S9 proposes, is safe and permitted — it is Apple's documented recommendation for apps ("To add the privacy manifest to your app or third-party SDK in Xcode…", captures/apple_privacy_manifest_files.txt), it makes the Xcode privacy report meaningful, and an accurate empty declaration cannot be wrong. What Phase 4 must not do is populate NSPrivacyAccessedAPITypes with categories the app does not use, because Apple binds you to what you declare — verbatim from the required-reason page: "You may use these APIs and the data derived from their use for the declared reasons only." So: optional, not required; if added, it must be empty.

Contrarian B — keep.xml currently works — CONFIRMED, with a three-state proof

S9 asserted the shrinker line. I proved causation on my own builds, in three states, on the copy.

State 1 — keep.xml present (proof/01_findings/S9_refute/apk_signing_refute.txt, TREE_STATE: CLEAN), verbatim:

=== shrinker keep line ===
201:raw:cadence_alarm:2131558400 reachable from keep xml file
=== R8 header ===
# compiler: R8
# compiler_version: 9.0.32
# min_api: 24
=== wav in apk ===
   142928  01-01-1981 01:01   res/pC.wav

State 2 — keep.xml DELETED (proof/01_findings/S9_refute/keepxml_negative_control.txt, header stamps TREE_STATE: DIRTY (1 path(s) modified) and TREE_DIFF: D android/app/src/main/res/raw/keep.xml), verbatim:

--- MUTATION: android/app/src/main/res/raw/keep.xml DELETED ---
 D android/app/src/main/res/raw/keep.xml
✓ Built build/app/outputs/flutter-apk/app-release.apk (53.5MB)
=== grep cadence_alarm in shrinker report ===
923:raw:cadence_alarm:2131558400 is not reachable.
=== res wav entries in APK ===
(grep exit 1)

The resource flips from reachable from keep xml file to is not reachable., no res/*.wav entry survives in the APK at all, and the APK shrinks from 53.6 MB to 53.5 MB. That is the v0.3 outage reproduced on demand.

State 3 — restored (proof/01_findings/S9_refute/keepxml_restored_rebuild.txt, TREE_STATE: CLEAN), verbatim:

--- keep.xml restored, rebuilding ---
✓ Built build/app/outputs/flutter-apk/app-release.apk (53.6MB)
201:raw:cadence_alarm:2131558400 reachable from keep xml file
   142928  01-01-1981 01:01   res/pC.wav

git status --porcelain on the copy is empty after the revert; the pinned repo was never touched.

Verdict: the contrarian call is right and now has a causal proof rather than an observation. It also converts S9's proposed regression test from a suggestion into a demonstrated procedure — Phase 4 can implement the post-build APK assertion and validate it against exactly these three states.


Part 4 — The permissions verdict

S9's headline — 0 of 8 unjustifiable, 2 policy-risky, USE_FULL_SCREEN_INTENT the real risk — is CONFIRMED. I re-read all eight declarations at android/app/src/main/AndroidManifest.xml:2-17 and traced each to its consumer. I found no ninth permission, no unjustifiable declaration, and no error in S9's table beyond what is noted below.

The policy text, from my own capture. proof/01_findings/S9_refute/captures/play_sensitive_permissions.txt:490-495 (https://support.google.com/googleplay/android-developer/answer/9888170?hl=en, retrieved 2026-08-04T10:42Z), verbatim, including the list exactly as laid out:

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.

The full auto-grant list is two items: "setting an alarm" and "receiving phone or video calls". A kitchen timer is named nowhere in it.

Is a kitchen timer inside or outside the list? Outside, as written. The asymmetry S9 identified is real and I verified it on the same page. Sixty lines earlier, at play_sensitive_permissions.txt:461-466, the exact-alarm policy reads verbatim:

Acceptable use cases for using the Exact Alarm Permission

Your app must use the USE_EXACT_ALARM functionality only when your app's core, user facing
functionality requires precisely-timed actions, such as:

The app is an alarm or timer app.
The app is a calendar app that shows event notifications.

Google wrote "an alarm or timer app" when it meant to include timers, on the same page, in the same document. Sixty lines later it wrote "setting an alarm" and stopped. Drafting that deliberate cannot be read as an oversight. Cadence therefore has a strong claim under USE_EXACT_ALARM and a contested one under USE_FULL_SCREEN_INTENT, and must plan for the permission not to be auto-granted.

The declaration deadline, from captures/play_fgs_fsi_requirements.txt:74 (https://support.google.com/googleplay/android-developer/answer/13392821?hl=en, retrieved 2026-08-04T10:42Z), verbatim: "If you use the USE_FULL_SCREEN_INTENT permission, you are required to complete the Play Console declaration starting May 31, 2024 […] 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 […] developers will need to prompt users to grant permission on new installs and gracefully degrade the experience if denied."

Google requires both the prompt and the graceful degradation. Cadence has neither.

The code claim — CONFIRMED, and the method does exist.

$ grep -rn "requestFullScreenIntentPermission" lib/ android/ test/
(no output, exit 1)

It is not that the plugin lacks the call. flutter_local_notifications 22.1.0 exposes it at ~/.pub-cache/hosted/pub.dev/flutter_local_notifications-22.1.0/lib/src/platform_flutter_local_notifications.dart:193:

  Future<bool?> requestFullScreenIntentPermission() async =>
      _channel.invokeMethod<bool>('requestFullScreenIntentPermission');

The app simply never calls it. Backstop.init (lib/alarm_backstop.dart:77-86) requests exactly two permissions and wires exactly one degraded path:

      final notif = await android?.requestNotificationsPermission();
      final exact = await android?.requestExactAlarmsPermission();
      Journal.log('permissions',
          'notifications=${notif ?? 'n/a'} · alarmes exactes=${exact ?? 'n/a'}');
      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);
      }

fullScreenIntent: true is set unconditionally at lib/alarm_backstop.dart:53, and the two requests sit at lines 79-80 with the single Diag.fail('backstop-notif', …) at line 85. No request, no Diag.fail, no fallback for full-screen intent. S9's HIGH is correct and its proposed fix is the right one.

Small correction to S9's Table 1 and its clean-areas table, neither of which changes a verdict: S9 states the highest iOS deployment floor among plugins is 13.0 and names flutter_local_notifications among the packages declaring it. Read from the packages themselves, flutter_local_notifications 22.1.0 declares .iOS("11.0"), wakelock_plus 1.7.0 declares .iOS("11.0") and vibration 3.2.0 declares .iOS("12.0"); the 13.0 floor comes from audioplayers_darwin, share_plus, device_info_plus, shared_preferences_foundation and package_info_plus. The conclusion — IPHONEOS_DEPLOYMENT_TARGET = 13.0 satisfies every floor — is unchanged, and 13.0 is also what the Flutter 3.44.8 template itself emits (templates/app/ios.tmpl/Runner.xcodeproj/project.pbxproj.tmpl:383,512,563). PASS upheld.


Part 5 — S9-F16 re-examined: the causal claim and the fix are wrong

S9's declaration facts are correct: android/app/src/main/AndroidManifest.xml:48-56 declares the boot receiver android:exported="false" with four actions, and grep -c QUICKBOOT_POWERON returns 2. Its verdict that BOOT_COMPLETED still works is correct and well sourced.

Its claim that the two OEM actions cannot be delivered is not proven by the evidence it cites. The <receiver> element reference S9 quotes says, verbatim, that a non-exported receiver receives "those sent by the system, components of the same application, or applications with the same user ID". OEM quick-boot senders on HTC-lineage firmware are platform-signed system applications, and on those devices they commonly run under the system user ID — in which case the broadcast does reach a non-exported receiver. The second quote S9 uses (the RECEIVER_EXPORTED flag from the broadcasts overview) governs context-registered receivers on Android 13+, not manifest-declared ones, so it does not carry the claim. Whether any given OEM sender shares the system UID is device-specific and I have no device to test it on.

Verdict: facts CONFIRMED, causal claim REFUTED as unproven, severity stays LOW, proposed fix REFUTED. Deleting the two lines is a small, unnecessary risk: they cost nothing, they are inert at worst, and on the exact hardware they were added for they may be the only re-arm path. Phase 4 should leave them and, if anything, replace S9's proposed deletion with a comment recording that their delivery depends on the OEM sender's UID. The precise test that would settle it: install a debug build on an HTC-lineage device, adb shell am broadcast -a android.intent.action.QUICKBOOT_POWERON -p dev.sergemio.cadence from a non-system shell, and observe whether ScheduledNotificationBootReceiver runs.


Part 6 — Findings S9 MISSED

Three, each held to R2.

S9R-M01 — The iOS launch image is also the untouched Flutter template, and S9's manifest records the opposite

  • Severity: LOW
  • Location: ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png, LaunchImage@2x.png, LaunchImage@3x.png (valid at 03a176e)
  • What is wrong: S9's coverage manifest states these three files were "MD5-compared against the template: all three differ, so the launch image was customised", and Table 2 item 8 records a "custom LaunchImage set that differs from the template". Both statements are false. S9 compared against the wrong template location. Measured against the real source of flutter create images, flutter_template_images-5.0.0, all three are byte-identical, and all three are 1×1 pixel transparent PNGs. The iOS launch screen is therefore the stock blank Flutter splash: a centred, invisible one-pixel image over the default background, while Android has a themed launch_background.xml for both light and dark. Nothing about the launch experience was ever configured for iOS.
  • Evidence:

``` $ 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

  • Why it matters for a restaurant kitchen: Small on its own — a blank splash for a fraction of a second. It matters because of what it corrects: S9 used "the launch image was customised" as the contrast that made the icons look like an isolated oversight. They are not isolated. Every image asset on the iOS side is the untouched template, which is the same conclusion the Info.plist key-set diff and the missing DEVELOPMENT_TEAM reach independently: the iOS target has never been configured at all.
  • Proposed fix: 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 the fix: 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

  • Severity: LOW
  • Location: web/icons/Icon-maskable-192.png and web/icons/Icon-maskable-512.png (valid at 03a176e)
  • What is wrong: S9-F12 covers the web target's text, and its coverage manifest records that "the four icon entries checked; icon files confirmed present on disk" — presence, not content. Content is where the defect is, and it is asymmetric: favicon.png, Icon-192.png and Icon-512.png are not the Flutter template, so someone did replace the web icons — but the two purpose: maskable entries, the ones Android Chrome actually uses when a PWA is added to a home screen, were left as the template. A tablet that installs the web build gets the Cadence mark in the browser tab and the Flutter logo on the home screen.
  • Evidence:

$ 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" }

  • Why it matters for a restaurant kitchen: Only if the web build is ever shown to a restaurant, which is the same condition S9-F12 already accepts. Under that condition it is the same failure as S9-F02 on a smaller stage: a third party's logo standing in for the product, on the icon a chef taps.
  • Proposed fix: 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 the fix: A shell assertion in CI: neither maskable icon's md5 may equal its flutter_template_images-5.0.0 counterpart. Red now (both equal), green after.

S9R-M03 — The app localises itself manually into French and English, but the iOS bundle declares English only

  • Severity: MEDIUM
  • Location: ios/Runner/Info.plist:4-69 (CFBundleLocalizations absent) and ios/Runner.xcodeproj/project.pbxproj:196-201 (valid at 03a176e)
  • What is wrong: Cadence is a bilingual product for a French market. lib/i18n.dart carries full fr and en string tables, lib/ui/modals.dart:626 offers a French/English picker, and lib/engine/store.dart:293-294 starts a fresh install in French when the tablet's language is French. None of that is declared to iOS. CFBundleLocalizations is the key Apple defines for exactly this arrangement, and it is absent; the Xcode project declares developmentRegion = en and knownRegions = (en, Base); there is no fr.lproj. The bundle asserts, to the system and to the store, that this is an English-only app. This is not template drift that happens to be harmless like AppFrameworkInfo.plist — it is a declaration the product's own behaviour contradicts.
  • Evidence:

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.

  • Why it matters for a restaurant kitchen: A French brigade unboxes an iPad set to French. If PlatformDispatcher.instance.locale reflects the bundle's declared localisations rather than the device's raw preferred languages, seedLangFor receives en, and the first thing the kitchen sees is an English timer board it must find a settings screen to change — in a room where nobody has a free hand. Android is unaffected: it has no equivalent bundle-localisation gate.
  • Proposed fix: 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 the fix: 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.


Part 7 — Coverage manifest

Every file in S9's declared scope, plus the four the brief named that S9's own manifest omitted (marked ★). "Checked" means I opened the file at the pinned sha and inspected it myself.

File Lines / size What I checked, independently of S9
android/app/build.gradle.kts 51 Read in full. namespace (8), compileSdk/ndkVersion delegation (9-10), Java 17 + desugaring (12-17), applicationId + stale TODO (20-21, grep -c = 1 → F13 confirmed), version delegation (24-27), release signingConfig = signingConfigs.getByName("debug") (30-36 → F01 confirmed by my own build), Kotlin JVM 17 (39-43), desugar 2.1.4 (46). Confirmed no minifyEnabled/shrinkResources line and that R8 9.0.32 ran anyway on my build.
android/build.gradle.kts 24 Read in full. Repositories, relocated build dir, evaluationDependsOn(":app"), clean. No store-facing declaration. No finding.
android/settings.gradle.kts 26 Read in full. Flutter SDK resolution from local.properties; AGP and Kotlin plugin versions. My build in the copy resolved them and exited 0. No finding.
android/gradle.properties 6 Read in full. -Xmx8G -XX:MaxMetaspaceSize=4G … verbatim → F15 confirmed. Verified the other three are template defaults.
android/gradle/wrapper/gradle-wrapper.properties 5 Read. Distribution URL consistent with the AGP version; my copy built with it. No finding.
.gitignore (repo root) 45 Read in full. Confirmed /build/, /coverage/, .dart_tool/, .flutter-plugins-dependencies, app.*.symbols, app.*.map.json, and /android/app/{debug,profile,release}. Nothing sensitive is trackable and nothing required is ignored. No finding.
android/.gitignore 14 Read in full. Confirmed key.properties, **/*.keystore, **/*.jks at lines 12-14 — the exclusions the F01 fix depends on already exist. Wrapper and local.properties exclusions confirmed. No finding.
ios/.gitignore 34 Read in full. Confirmed Flutter/Generated.xcconfig, Flutter/ephemeral/, Flutter/flutter_export_environment.sh, **/Pods/, xcuserdata, and Runner/GeneratedPluginRegistrant.* (line 28). Cross-checked with git ls-files ios/: the registrant and flutter_export_environment.sh are present on disk and correctly untracked. No signing material is trackable. No finding.
.metadata 30 Read in full. project_type: app; revision/channel recorded; the migration.platforms block lists only root and web — it does not describe the android or ios platforms the project ships, which is consistent with the iOS template drift found everywhere else. No finding, and deliberately so: the consequence one would expect does not exist, because flutter migrate has been removed from the CLI — flutter migrate --help returns Could not find a command named "migrate". Nothing reads the block. Recorded rather than inflated into a nit.
android/app/src/main/AndroidManifest.xml 79 Read in full. All eight <uses-permission> traced to consumers (Part 4). <application> attributes at 18-21 → F10 confirmed. MainActivity exported="true" + MAIN/LAUNCHER (22-43) — correct. taskAffinity="" noted: not in the Flutter template, consistent with task-hijacking hardening, no harm provable, no finding. Both receivers (46-56) → F16 re-examined, Part 5. flutterEmbedding meta-data (59-61). Both <queries> (68-78).
android/app/src/debug/AndroidManifest.xml 7 Read in full. INTERNET only. Proven absent from my own merged release manifest. No finding.
android/app/src/profile/AndroidManifest.xml 7 Read in full. Identical to debug. Same verification. No finding.
Merged release manifest (my build, in the copy) 181 Read as the oracle. minSdkVersion="24" targetSdkVersion="36", versionCode="18"; grep -c android:allowBackup = 0 → F10; grep -c android:debuggable = 0; <application> opening tag inspected — no backup attributes present after merge.
android/app/src/main/res/raw/keep.xml 6 Read in full → F11, proved causal in three states (Contrarian B).
android/app/src/main/res/raw/cadence_alarm.wav 142,928 bytes Confirmed it lands in my APK as res/pC.wav at the identical byte length, and vanishes entirely when keep.xml is removed.
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml 14 Read. Background/foreground/monochrome layers present. No finding.
android/app/src/main/res/values/colors.xml 3 Read. ic_launcher_background = #F4EFE4, matching pubspec.yaml. No finding.
android/app/src/main/res/values/styles.xml 18 Read. LaunchTheme/NormalTheme. No finding.
android/app/src/main/res/values-night/styles.xml 18 Read. Dark parallel. No finding.
android/app/src/main/res/drawable/launch_background.xml 12 Read. No finding.
android/app/src/main/res/drawable-v21/launch_background.xml 12 Read. No finding.
android/…/res/drawable-{m,h,xh,xxh,xxxh}dpi/ic_launcher_foreground.png 5 files Present at all five densities. No finding.
android/…/res/drawable-{m,h,xh,xxh,xxxh}dpi/ic_launcher_monochrome.png 5 files Present at all five densities; themed-icon layer complete. No finding.
android/…/res/mipmap-{m,h,xh,xxh,xxxh}dpi/ic_launcher.png 5 files Present at all five densities; not template artwork. No finding.
android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt (read 40-70 for the volume channel only) setStreamVolume + catch (_: Exception) {} at line 57, verbatim → F06 confirmed. Runtime behaviour is S3's; only the declaration consequence judged here.
ios/Runner/Info.plist 70 Read in full and diffed key-set against the Flutter 3.44.8 template — zero differences. UIBackgroundModes absent (grep -c = 0) → F03. ITSAppUsesNonExemptEncryption absent (grep -c = 0) → F08 confirmed. CFBundleLocalizations absent → M03. CFBundleName = cadence lowercase against CFBundleDisplayName = Cadence: checked, no user-visible consequence found, no finding. Orientations, scene manifest, LSRequiresIPhoneOS all confirmed as S9 reported.
ios/Runner.xcodeproj/project.pbxproj 644 Grepped and read every signing, identity and deployment block. DEVELOPMENT_TEAM count = 0; build-setting-name diff against the template shows DEVELOPMENT_TEAM as the sole omission → F04. CODE_SIGN_STYLE = Automatic at 397/414/429 confirmed to belong to RunnerTests by its bundle id. IPHONEOS_DEPLOYMENT_TARGET = 13.0 at 363/489/540 — equals the template's own value and clears every plugin floor. TARGETED_DEVICE_FAMILY = "1,2". ENABLE_BITCODE = NO at 379/558/580 — correct, bitcode is retired. objectVersion = 54, LastUpgradeCheck = 1510: Xcode 26 will offer a settings update, which is a prompt, not a failure — no finding. developmentRegion/knownRegions at 196-201 → M03. PBXResourcesBuildPhase bundles four items, none audio → F07. SPM wiring (XCLocalSwiftPackageReference at 629-634, FlutterGeneratedPluginSwiftPackage) consistent with the absent Podfile.
ios/Flutter/Debug.xcconfig 1 Read. #include "Generated.xcconfig". No finding.
ios/Flutter/Release.xcconfig 1 Read. Identical. No finding.
ios/Flutter/AppFrameworkInfo.plist 24 Read in full. CFBundleDevelopmentRegion en, CFBundleIdentifier io.flutter.flutter.app, no MinimumOSVersion — a template property, not a local omission. No finding.
ios/Flutter/Generated.xcconfig 15 Read in full (untracked, generated). FLUTTER_BUILD_NAME=0.4.12, FLUTTER_BUILD_NUMBER=18 feed the plist substitutions. Confirmed it defines no DEVELOPMENT_LANGUAGE, so $(DEVELOPMENT_LANGUAGE) resolves from developmentRegion = en → M03.
ios/Flutter/flutter_export_environment.sh ★ generated Confirmed present on disk, gitignored at ios/.gitignore:26, absent from git ls-files. No local absolute paths are tracked. No finding.
ios/Runner/Base.lproj/Main.storyboard 26 Read in full. Single FlutterViewController scene, matching UIMainStoryboardFile and the scene manifest. No finding.
ios/Runner/Base.lproj/LaunchScreen.storyboard 37 Read in full. Centred LaunchImage with centre constraints — the image it centres is a 1×1 transparent PNG → M01.
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json 122 Read in full. All 15 declared slots backed by a file on disk; ios-marketing 1024 present. Completeness fine, content is the defect.
ios/Runner/Assets.xcassets/AppIcon.appiconset/*.png 15 files md5-compared against flutter_template_images-5.0.0: all 15 IDENTICAL → F02, strengthened. sips on the 1024: 1024×1024, hasAlpha: no, PNG → the mechanical alpha check passes.
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json 23 Read in full. Three universal scales, all backed. No finding.
ios/Runner/Assets.xcassets/LaunchImage.imageset/*.png 3 files md5-compared against flutter_template_images-5.0.0: all three IDENTICAL, all 1×1 px → M01, refuting S9's manifest entry.
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md 4 Read in full. Stock template guidance, unchanged. No finding.
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme 119 Read all four action blocks. TestAction at 43-71 includes RunnerTests.xctest, skipped = "NO"F14 confirmed. buildConfiguration per action: Build/Test Debug, Profile Profile, Analyze Debug, Archive Release (line 116) — correct for App Store archives. No finding beyond F14.
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata 7 Read in full. Self-reference only. No finding.
ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/{IDEWorkspaceChecks.plist, WorkspaceSettings.xcsettings} 2 files Both present and tracked. These are the files whose absence makes Xcode re-prompt on every open; they are here. No finding.
ios/Runner.xcworkspace/contents.xcworkspacedata 7 Read in full. No Pods.xcodeproj — consistent with SPM mode and the absent Podfile. No finding.
ios/Runner.xcworkspace/xcshareddata/{IDEWorkspaceChecks.plist, WorkspaceSettings.xcsettings} 2 files Both present and tracked. No finding.
ios/RunnerTests/RunnerTests.swift 12 Read in full. testExample() has a body of two comments and zero assertions → F14 confirmed.
ios/Runner/SceneDelegate.swift 6 Read in full. Empty FlutterSceneDelegate subclass matching the scene manifest. No finding.
ios/Runner/Runner-Bridging-Header.h 1 Read in full. GeneratedPluginRegistrant.h import. No finding.
ios/Runner/GeneratedPluginRegistrant.{h,m} 2 files Present on disk, gitignored at ios/.gitignore:28, absent from git ls-files — regenerated per build, so no staleness risk. No finding.
ios/Runner/AppDelegate.swift (read 1-60 for declaration-relevant content) Confirmed the ⚠️ NOT YET COMPILED header, the .playback rationale comment, and that cadence/volume returns nil on both methods. Declaration-side only; the runtime is S3's.
pubspec.yaml 68 Read the platform-facing declarations. version: 0.4.12+18; flutter_launcher_icons with ios: false → F02; description at line 2 as the F12 fix source.
pubspec.lock Resolved the nine Darwin plugin versions and cross-checked each against .flutter-plugins-dependencies — which is where I found the ninth, package_info_plus 10.2.1, missing from S9's F09 table.
.flutter-plugins-dependencies generated Parsed the plugins.ios array as the authoritative link list for F09. Nine entries, listed in Part 3.
web/manifest.json 35 Read in full → F12 confirmed verbatim at lines 2-9. Icon entries checked by content, not presence → M02.
web/index.html 46 Read the metadata region. description/apple-mobile-web-app-title/<title> at 21/26/32 → F12 confirmed verbatim.
web/icons/*.png, web/favicon.png 5 files md5-compared against flutter_template_images-5.0.0: the two maskable icons are the template, the other three are not → M02.

Captures. 25 files under proof/01_findings/S9_refute/captures/, every one fetched by me with utilities/chrome.py::render_page (headless Chromium), never WebFetch, never requests.get(), per R4. 20 returned HTTP 200 and are the ones I quote; captures/INDEX.json records URL, HTTP status, UTC retrieval time and character count for every attempt, including the failures.

Failures, listed rather than hidden, none of which carries a claim in this report:

  • HTTP 404 — …/bundleresources/privacy-manifest-files/describing-use-of-required-reason-api (the live path drops the privacy-manifest-files segment; that one returned 200 and is what I quote), …/avfaudio/enabling-background-audio, and the File-timestamp category anchor.
  • Timed out and not retried — …/information-property-list/cfbundledevelopmentregion, developer.apple.com/help/app-store-connect/reference/app-information, developer.android.com/guide/topics/resources/app-languages. The first two were the pages that would have let me state an App Store product-page consequence for S9R-M03; without them I state no such consequence.

apple_uibackgroundmodes.txt and apple_uibackgroundmodes_audio_key.txt are the same URL captured twice, byte-identical.

Apple's documentation pages never reach network idle. The first pass used the module default (wait_until="networkidle") and timed out on every Apple URL; the successful pass used wait_until="domcontentloaded" with a 6-second settle and a 60-second ceiling.

Read-only compliance (R10). the app repository was never written to. git -C the app repository status --porcelain returns empty and HEAD is 03a176e72ef0075eec86b8915cbe6e93042a3b9d. All three of my builds and the one mutation ran in a scratch working copy, whose tree state is stamped in every proof header (CLEAN, DIRTY (1 path(s) modified), CLEAN).

Stream S10: finding and refutation

S10 — Product and feature review for professional kitchensfindings/S10_product.md · raw .md

S10 — Product and feature review for professional kitchens

Subject: the app repository @ 03a176e72ef0075eec86b8915cbe6e93042a3b9d (0.4.12+18) Question: does this product do the job it claims, for the head chef who would pay for it? Constraint honoured: R6 — nothing below designs or builds a feature. Every gap is reported with its commercial consequence and, where a fix implies a product decision, with the trade-off, not an instruction. Read-only: the working tree was clean at the pinned sha before and after this stream (git status --porcelain → empty, proof/01_findings/S10/greps.txt line 1).

Method. Every tap count below is read out of the gesture handler that receives it, not estimated from a screenshot. Board geometry is computed from lib/ui/grid_layout.dart:62-108 by arithmetic replication, cross-validated against the repo's own pinned expectations (test/grid_layout_test.dart:110-118 — 4 dishes → cols 2, tileW 602, rowH 332, gap 21, pad 27, reproduced exactly) — see proof/01_findings/S10/grid_capacity.txt. Grep evidence is in proof/01_findings/S10/greps.txt. Prior decisions L1 (zones deleted), L4 (15 % volume floor), L6 (ringtone design), L10 (1.2 s lead), L11 (backstop timing), L12 (batch numbers) are taken as settled and are not re-litigated; where a finding touches one, it states the residual consequence of the decision, not an argument against it.


1. Tap counts for the core service flows

Every row is derived from code. "Taps" means finger-downs on the glass, excluding characters typed on the on-screen keyboard, which are counted separately.

Flow Taps Handler chain (valid at 03a176e)
Start one known dish (idle → running) 1 tile.dart:372-375home.dart:348,356-363engine.dart:170 — no debounce on this branch, it starts on the down-up
Start three known dishes 3 same handler, once per tile; no multi-select or "start all" exists in lib/
Add a second pan of a dish already running (batch) 1 tile.dart:427-429home.dart:403-417engine.dart:191
Take one dish from idle to three pans 3 1 start + 2 chip taps; hard cap 3 (engine.dart:31,194)
Silence one ringing alarm 1 home.dart:365-374 — the whole tile is the target, the ±/✕ row is hidden while ringing (tile.dart:569-570)
Silence one alarm while two others ring 1 (3 to clear all) same; each tile is independent
Silence a ringing alarm while edit mode is on 2 minimum, 4 if the cook taps the tile first home.dart:349-352 opens the editor instead of silencing; recover via ✓ Done (header.dart:104-111) then the tile, or Cancel (modals.dart:355) → ✓ Done → tile
Silence a ringing alarm while the editor modal is open 2 modals.dart:355-356 Cancel, then the tile — the modal barrier makes the board unreachable
Pause a running dish 1, applied 260 ms later home.dart:386-399, engine.dart:51
Reset a running dish to full duration 2 inside 260 ms home.dart:377-385
Clear a running dish off the board 1 (the ✕) tile.dart:618-625home.dart:656-663
Add 1 minute to a running dish 6 tile.dart:601-608engine.dart:228 (+10 s per tap, no repeat-on-hold)
Add 5 minutes to a running dish 30 same
Extend a dish that is already ringing impossible tile.dart:569-570 hides ± while ringing; nearest path is stop (1) + restart at full duration (1)
Create one dish whose duration is one of the six presets 5 + name typing header.dart:113-120, modals.dart:249,288-306,321-331,358
Create one dish at 7:30 (not a preset) 14 + name typing modals.dart:428 (+1 min/tap), :436 (+5 s/tap), from the 3:00 default (:217)
Delete one dish 3 for the first, 2 thereafter header.dart:104 Edit, home.dart:349-351 tile, modals.dart:348-351 🗑 — no confirmation step exists
Clear the 7 dishes the app ships with 15 store.dart:326-343
Set up a 20-dish menu on a fresh tablet 116 at best, 296 if no duration is a preset 16 to clear the seed + 20 × (5…14), plus 20 names typed on glass

Reading of the table: the two flows that matter most in the rush — start a known dish, silence a ringing alarm — are both one tap, which beats a shouted "six minutes on the salmon". Every flow that repairs a mistake is expensive or impossible, and configuration is a three-hundred-tap onboarding.


2. Findings

S10-F1 — A restaurant's entire configuration exists in one copy, on one tablet, with no export and no way back

  • Severity: BLOCKER
  • Location: lib/engine/store.dart:15-24 (the only persistence keys), lib/journal.dart:207 (the only export in lib/), lib/ui/modals.dart:616-693 (Settings has no configuration export/import control)
  • What is wrong: the menu a kitchen builds — names, durations, chained phases, per-dish tones, announcements — lives only in this tablet's SharedPreferences. There is no export, no import, no backup, no second-device transfer, and no restore. The single export symbol in the whole of lib/ is Journal.exportCopy(), which ships the diagnostic flight recorder to a share sheet, not the timers. A tablet that is lost, stolen, factory-reset, water-damaged or replaced takes the configuration with it, and the only recovery is the 116-to-296-tap re-entry from the table above.
  • Evidence: $ 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)
  • Why it matters for a restaurant kitchen: this is the gap that decides whether a group buys. A two-site operator cannot roll a standard menu out to a second kitchen; they re-type it. A chain of ten cannot adopt at all. And within one site, the configuration is one dropped tablet away from a service run on a phone timer. Prior work already recorded this as still open (A1-4 / A1-R5); what this stream adds is the price tag: 116-296 taps and 20 typed names, per tablet, forever.
  • Proposed fix (reported option, R6 — this is a feature and is out of scope to implement): the smallest form that closes it is a Settings action that writes the same JSON already produced by Store.saveDefs (store.dart:149) to a file through the share_plus dependency the app already carries (pubspec.yaml:22), and a paste-or-open counterpart that validates through the existing TimerDef.fromJson salvage path (store.dart:91-113). Trade-off to decide before building: an import that merges is ambiguous about duplicates; an import that replaces can wipe a kitchen, which is exactly the class of bug seedIfFresh was hardened against (store.dart:300-310).
  • How to prove the fix: 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.

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

  • Severity: BLOCKER
  • Location: lib/ui/tile.dart:618-625 (✕), lib/ui/modals.dart:348-351 (🗑), lib/engine/engine.dart:375-390 (Save on an existing dish), lib/ui/home.dart:377-385 (double-tap reset)
  • What is wrong: four separate one-tap actions destroy state irrecoverably and none of them asks. (a) on a running tile removes the run entry; the deadline is gone and the only path back is a restart at full duration. (b) 🗑 in the editor pops straight out with TimerEditorResult(delete: true) and home.dart:431-433 deletes the definition — no dialog, no trash, no restore. (c) Pressing Save on a dish that is currently running silently kills its run and every batch of it: saveDef does run.remove(t.id) then removeClonesOf(t.id) before applying the edit. The edit-mode veil deliberately keeps the digits ticking underneath (tile.dart:297-304), so the operator can watch a live timer and still lose it by saving. (d) A double tap inside 260 ms resets a running dish to full duration. There is no undo anywhere in lib/ and no onLongPress/onDoubleTap guard on any of them (greps.txt §A, §C).
  • Evidence: 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))),
  • Why it matters for a restaurant kitchen: with wet hands and a tablet at arm's length, mis-taps are the normal case, not the exception. A head chef evaluating this board will ask what happens when a commis leans on it. Today: three pans of fries vanish, or the dish itself vanishes from the menu with no way to get it back except retyping it. The severity is BLOCKER rather than HIGH because (c) loses live run state during service and (b) permanently loses configuration that S10-F1 proves cannot be restored from anywhere.
  • Proposed fix: 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 the fix: 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

  • Severity: HIGH
  • Location: lib/ui/home.dart:348-353
  • What is wrong: _tapTile tests editing before it tests the run status, so while the board is in edit mode a ringing dish cannot be silenced from its own tile. The tap opens the timer editor; the alarm keeps ringing behind a modal barrier that also makes every other tile unreachable. If the ringing tile is a batch clone, the handler returns immediately and the tap does nothing at all. Recovery is 2 taps if the cook knows to press ✓ Done first, 4 if they tap the tile and have to Cancel out of the editor.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: edit mode is reachable in one tap from the header and there is nothing that leaves it automatically. A cook who opens Edit to fix a duration and then walks away leaves the board in a state where the next alarm cannot be stopped by the obvious gesture. In an 80 dB kitchen the operator's first instinct is to hit the red pulsing tile; here that opens a form.
  • Proposed fix: 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 the fix: 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.

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

  • Severity: HIGH
  • Location: lib/ui/tile.dart:384 (showDup = widget.dupShow && !widget.editing), :417-468 (the chip), lib/ui/home.dart:624 (dupShow = status != null && cnt < Engine.maxBatch)
  • What is wrong: dupShow is true for any non-null run status, ringing included, so a ringing tile still carries a live #N button. _topGroup renders it immediately to the right of the dish name, and that group is centred in the upper third of the tile (tile.dart:268Expanded(child: Center(...))). The chip has its own GestureDetector with an extended hitbox (:427-431), so it wins the hit test over the tile. On the reference 4-dish board (tile 602 × 332) the chip is roughly 70 × 40 px plus hit padding, sitting in the middle-upper region of the tile — precisely where a flat hand lands. The result of that mis-tap is a new pan started at full duration, while the original alarm keeps ringing.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the one gesture the product must never get wrong is "make the noise stop". Here the noise-stop gesture and the start-another-pan gesture overlap on the same tile, and the wrong one is silent about what it did. The cook then has two things ringing and counting that they did not ask for.
  • Proposed fix: 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 the fix: a test asserting dupShow == false when status == RunStatus.ringing — the expression currently makes it true.

S10-F5 — Two dishes ringing at once cannot be told apart by sound: one player, and the second ring stops the first

  • Severity: HIGH
  • Location: lib/audio/audio.dart:19 (AudioPlayer? _ring), :62, :71
  • What is wrong: every ringtone in the app plays through a single dedicated AudioPlayer, and _play calls await p.stop() before starting the next asset. The comment at :14-18 explains the choice for the same timer's repeats — a 4 s tone with a 2 s repeat floor would otherwise layer on itself. The consequence for different timers was not carried through: when a second dish fires while the first is still sounding, or on any of the interleaved repeats, the newer tone stops the older one. Two dishes ringing therefore never produce two distinguishable tones at the same instant; they produce whichever fired last.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the twelve tones (theme.dart:45-48) and the per-dish sound are the product's answer to "which dish is that?". The answer holds for one alarm and degrades exactly when a second alarm arrives, which is the only time the question is asked. The voice announcement is the fallback, and it is also serialised, with a 20 s staleness drop (voice.dart:36,180-188) — under three simultaneous alarms some dishes are never named at all.
  • Proposed fix: 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.
  • How to prove the fix: 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.

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

  • Severity: HIGH
  • Location: lib/engine/engine.dart:279, lib/ui/tile.dart:199-201
  • What is wrong: _fireAlarm sets r.rangAt = n, the instant the ring actually started, and the tile's ringing display counts up from that value. When the app was killed, the tablet rebooted or the battery died, the alarm fires on the first tick after relaunch — so rangAt is the relaunch instant and the tile reads +0:00 for a dish that came out of the oven forty minutes ago. The true lateness is computed and stored (r.driftMs, engine.dart:276-278) and written to the journal (home.dart:288-292), but driftMs is not among the twenty fields TileView receives (tile.dart:12-34), so the operator never sees it.
  • Evidence: 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);
  • Why it matters for a restaurant kitchen: the count-up exists to tell the cook how long a dish has been sitting. That is the number that decides whether it goes to the pass or in the bin. After the exact events the product is least able to prevent — a kill, a reboot, a flat battery — the number becomes a confident lie in the safe direction. A chef who discovers that once will stop trusting the board.
  • Proposed fix: 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 the fix: 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

  • Severity: HIGH
  • Location: lib/engine/models.dart:29-49 (the whole TimerDef surface), lib/engine/store.dart:311-317
  • What is wrong: a timer carries id, name, durationSec, sound, phrase, steps — and nothing else. legacyZoneId exists solely to be read once by the migration and is deliberately not serialised (models.dart:37-39,78). There is no station, no owner, no start-time stamp, no colour band, no grouping, and no view filter. The v0.4.11 decision to delete zones (L1) is sound on its own terms and is not re-argued here: it fixed a real discoverability failure, and the cost stated in the commit was "a station of three dishes is three edits, not one". What the decision also removed, and what the code shows was not replaced, is the only object in the product that answered "whose alarm is that?". The seed still encodes stations — but as sounds, invisibly: dart // lib/engine/store.dart:311-317 // Tones assigned by MEASURED carry through a tablet speaker (RMS above // 800 Hz) rather than by taste, and grouped by station the way the pilot // kitchen works: the fryer, where seconds decide, gets the loudest tone of // the set; the oven, where the deadline is a minute wide, gets the // calmest. ... const oven = 'Cascade', fryer = 'Chirp'; The tone→station mapping appears nowhere the operator can see it. The tone name is visible only inside the editor's picker (modals.dart:321-331); the board shows no tone at all.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: shift handover is the second thing a head chef tests. A cook taking the pass at 19:00 sees eleven tiles, three of them counting, and has no way to know that the Fries belong to the fryer station and the Dough to the oven — the app's own seed believes that mapping matters enough to encode it, and then hides it. In a two-station kitchen, a ringing alarm is addressed to nobody. So S10-F7 blocks the sale not because zones should return, but because some station signal is table stakes for any kitchen big enough to buy software for its timers.
  • Proposed fix (reported option, out of scope to implement): 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/ui/tile.dart:601-616, :569-570, lib/engine/engine.dart:228-241
  • What is wrong: the only correction affordance is ±10 s, one discrete tap each, with no repeat-on-hold (grep -rn "onLongPress" lib/ → no matches, greps.txt §C). A dish started 5 minutes short needs 30 taps. A dish that is already ringing has no ± at all, because _controls renders only for running and paused, so "it needs two more minutes" at the pass means stopping the timer and restarting it at its full duration. There is also no way to run a dish once at a different duration without editing the definition — and per S10-F2(c) that edit kills the run and every batch.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: "two more minutes on the chicken" is the single most common correction in a kitchen and it is the most expensive interaction in this product.
  • Proposed fix: none inside R6 — a ±1 min control or press-and-hold repeat is a new capability and belongs in the roadmap. What is inside R6: the dead debounce justification in alarm_backstop.dart:36-39 is now inaccurate documentation of live behaviour and should say so.
  • How to prove the fix: 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

  • Severity: MEDIUM
  • Location: lib/ui/theme.dart:16,25, lib/ui/tile.dart:281-282,759-786
  • What is wrong: the tile's whole visual encoding is a pie wedge whose area is proportional to time remaining (_PiePainter.paint, sector angle p * 2π). Urgency therefore scales as the inverse of the coloured area: a dish 5 % from ringing shows a red wedge covering 5 % of the tile, over a background that is the same colour token value as an idle tile — tileIdle and track are both 0xFFE7DED0. The code states the cost explicitly: dart // lib/ui/theme.dart:9-15 // Tuile au repos : MÊME valeur que `track` ... en connaissant le coût (une carte non // démarrée et une carte en fin de course ont le même fond ; ce qui les sépare // est le quartier coloré, les chiffres noirs au lieu de gris, et la rangée // ±10s qui n'existe que sur une carte lancée). So across a 12- or 20-tile board, a dish thirty seconds from ringing is separated from a dish nobody has started by a thin red sliver, an ink shade on the digits, and the presence of a small button row.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: at 20 dishes the tile is 244 × 173 logical px (proof/01_findings/S10/grid_capacity.txt) and read at arm's length across a pass. The glance question is "what is about to land?", and the design answers it with the smallest mark on the tile. This is a taste call Serge made knowingly for the idle colour; what is new here is the interaction with the pie geometry, which the comment does not cover.
  • Proposed fix: 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 the fix: 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.

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

  • Severity: MEDIUM
  • Location: lib/engine/engine.dart:31,194, lib/ui/home.dart:624
  • What is wrong: a dish supports one original plus two batches (maxBatch = 3). At the cap, dupShow goes false and the #N chip is simply not rendered. spawnClone returns null and the journal records «refuse (maximum atteint)» (home.dart:408-412), but that line goes to a diagnostic file the cook never reads. From the operator's side, the button that was there for the second pan is missing for the fourth, with no explanation.
  • Evidence: 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;
  • Why it matters for a restaurant kitchen: a fryer running four baskets of the same product is ordinary. The product's answer is a control that vanishes. The cook's workaround is to create a second dish called "Fries 2" — which, per S10-F1, is another entry they can never back up.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/engine/store.dart:296-343
  • What is wrong: a fresh install writes seven timers — Manouche, Mozzarella sticks, Fries, Crispy, Melt cheese, Dough, and a three-step Cook chicken. The code calls them what they are: "The pilot kitchen's real timers, in service order" (store.dart:296). Prior work already flagged that this diverged deliberately from the v2 decision to seed generic examples, and left the store-listing question open for this stream (prior work §2.4, "a store-facing product seeded with one kitchen's menu is a live question for S10"). The answer: clearing them is 15 taps with no confirmation, and there is no "start empty" or "clear all" path (grep -rniE "template" lib/ → no matches).
  • Evidence: 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]), ];
  • Why it matters for a restaurant kitchen: the first thirty seconds after install decide adoption. A restaurant that is not a Levantine grill opens the app and sees a stranger's menu it must delete dish by dish before it can start. It also reads as unfinished software to a buyer.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/ui/modals.dart:673-689,698-717, and the 30 Journal.log call sites in lib/ui/home.dart
  • What is wrong: there is no screen, list or panel that shows what was started, stopped, reset or missed. The only history that exists is Journal, and it is a diagnostic artifact by design: its lines are hard-coded French regardless of the app's language setting ('depart', 'ARRET', 'remise a zero', 'reprise', 'lot'home.dart:358,368,383,396,408), and the only way to see it is Settings → 📤 Send the log, which hands a text file to the OS share sheet. A cook taking over a shift cannot see the last half-hour of the board.
  • Evidence: 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 — ...',
  • Why it matters for a restaurant kitchen: combined with S10-F7 (no station, no owner), handover is entirely oral. The board answers "what is cooking now" and nothing else. That is acceptable for a single cook on a single station and thin for anything larger. It is not, on its own, a reason not to buy.
  • Proposed fix: 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 the fix: n/a — reported product gap.

S10-F13 — Below 150 px of tile width the app removes its own touch-target floor

  • Severity: LOW
  • Location: lib/ui/tile.dart:565-597
  • What is wrong: the ±10 s / ✕ row applies a 42 px height floor above 240 px of tile width, 38 px between 150 and 240 px, and no floor at all at or below 150 px, where sizing becomes purely proportional (minH = 0). With the 7 px vertical hit padding on each side (:607,615,623), the effective touch height is ~56 px in the top tier and ~52 px in the middle tier — both above Google's stated 48 dp guidance — and falls to roughly 37 px in the bottom tier. On the repo's own "petite tablette" reference of 800 × 540 (test/grid_layout_test.dart:32), tiles cross under 150 px wide at exactly 21 dishes (tile 127 × 108); on the 1280 × 740 reference, at 49 (tile 136 × 116) — both computed in proof/01_findings/S10/grid_capacity.txt.
  • Evidence: 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: "Consider 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.
  • Why it matters for a restaurant kitchen: only kitchens with very large menus on small tablets reach the bottom tier, and by then legibility is the binding constraint anyway. Recorded so it is not discovered as a surprise on a customer's 8-inch device.
  • Proposed fix: give the bottom tier a floor, as the two tiers above it already have. Defect repair.
  • How to prove the fix: a test asserting the computed button height at w = 140 is ≥ the floor — red today, since the branch sets minH = 0.

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

  • Severity: HIGH
  • Location: lib/alarm_backstop.dart:99-116,122-149,177-221,238-248, lib/ui/home.dart:174-204, lib/engine/store.dart:149-153, lib/main.dart:28-32
  • What is wrong: prior work established that the OS backstop has never been observed to actually ring (commit f46d142: «le secours OS n'a jamais reellement sonne dans ce log»). Assuming it does not, the product's behaviour in each failure mode is as follows, read from the code. The row that matters is the third: from the instant a timer starts ringing, it has no OS safety net at all, because _desired only covers runs whose status is running, so firing the in-app alarm cancels the scheduled notification.
  • Evidence: 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
- Why it matters for a restaurant kitchen: a chef buying an alarm board is buying the promise that the
noise happens. Four of the seven rows above end in silence with no operator-visible signal, and the
ringing-state hole is structural rather than incidental.
- Proposed fix: 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 the fix: 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.

3. Ranked gap list

Ranked by whether a head chef's purchase decision turns on it. "Blocks a sale" means: a competent buyer evaluating this against the alternative of shouting and a phone timer would decline, or would decline to roll it past one tablet.

# Gap Evidence Commercial consequence Blocks a sale
1 No export, import or backup of the timer configuration S10-F1; greps.txt §A/§B A group cannot roll out a standard menu; a dead tablet costs 116-296 taps to rebuild; there is no second copy anywhere YES
2 Every destructive action is one unconfirmed tap with no undo (✕, 🗑, Save-on-running, double-tap reset) S10-F2; engine.dart:375-380, modals.dart:348-351 The board cannot be trusted on the pass with more than one pair of hands; combined with #1, a curious commis can destroy the menu in 15 taps YES
3 No station, owner or grouping on a timer; the seed encodes stations as tones and never shows the mapping S10-F7; models.dart:29-49, store.dart:311-317 A two-station kitchen cannot tell which station a ringing alarm belongs to — which is every restaurant large enough to buy timer software YES
4 iOS has no OS-level safety net at all prior work A1-1 (iOS half), alarm_backstop.dart:73-76 — no DarwinInitializationSettings, so _ready stays false and every scheduling path returns An iPad kitchen gets the in-app alarm only, plus a permanent "backup alarm unavailable" banner (i18n.dart:124), so every failure row in S10-F14 is silent on iOS YES, on iPad
5 Only French and English, chrome and voice i18n.dart:9,23,40,88,166 — two blocks, two TTS locales Closes Spain, Italy, the Gulf and most US kitchens. A scope decision, not a defect YES, outside FR/EN
6 A ringing alarm has no OS backstop; a kill during the ring is total silence S10-F14; alarm_backstop.dart:103-106 The failure mode the product exists to prevent, in the window it is most likely to happen NO (invisible until it bites, then fatal)
7 In edit mode a ringing tile opens the editor instead of silencing S10-F3; home.dart:348-353 Seen in week one; reads as the product not knowing what its own priority is NO
8 The batch chip is live on a ringing tile, in the slap zone S10-F4; home.dart:624, tile.dart:384,417-468 The stop-the-noise gesture can start another pan instead NO
9 Two simultaneous alarms cannot be told apart by sound S10-F5; audio.dart:19,62,71 Defeats the twelve-tone feature exactly when it is needed NO
10 After an outage the count-up understates lateness by the whole outage S10-F6; engine.dart:279, tile.dart:199-201 A wrong number in the safe direction; destroys trust once discovered NO
11 Correction is ±10 s per tap, and impossible once ringing S10-F8; tile.dart:569-570,601-616 "Two more minutes" is the commonest kitchen correction and the most expensive interaction here NO
12 The app ships with one specific restaurant's seven dishes S10-F11; store.dart:296-343 First-impression and store-listing problem; 15 taps to clear NO
13 No operator-visible history of what was started or stopped S10-F12; modals.dart:673-717 Handover is oral; thin for anything beyond one cook on one station NO
14 Batch cap of 3 is enforced by hiding the button S10-F10; engine.dart:31,194, home.dart:624 A four-basket fryer has no representation and no explanation NO
15 Urgency is encoded as a shrinking coloured area on a background identical to idle S10-F9; theme.dart:16,25, tile.dart:778-779 The glance question the board exists to answer is answered by its smallest mark NO
16 Touch floor removed below 150 px tile width S10-F13; tile.dart:589-597 Only reached at 21+ dishes on a small tablet NO
17 Wakelock held for the whole service with no battery or charging warning S10-F14 row 6; main.dart:28-32 The most likely field failure has no in-app mitigation and no warning NO

Not gaps — recorded so the report can say so. One tap to start a known dish; one tap to silence an alarm, with the ±/✕ row deliberately hidden while ringing so the whole tile is the target (tile.dart:569-570); the ✕ given zero extra hitbox on the ± side as an explicit safety buffer (tile.dart:622-623); the alarm repeating forever with a shrinking gap rather than timing out (engine.dart:283,293-294); the 15 % volume floor asserted at boot and on the rising edge of every ring (L4, alarm_volume.dart:44-60); zero network dependency (greps.txt §G) with offline voices preferred (L8); and full state persistence on every mutation with absolute deadlines, so nothing drifts across sleep, kill or reboot (engine.dart:2-4, home.dart:252-258).


4. What would settle the judgements that code cannot settle

Two findings depend on seeing it run, and both are named rather than hedged.

  1. S10-F9 (urgency legibility). Settled by a rendered board at 12 and 20 dishes with one tile at 5 % remaining, photographed at 1.5 m, graded by Serge. Not decidable headless.
  2. S10-F5 (simultaneous alarm audibility). Settled by firing three dishes with three different tones within two seconds on the Lenovo TB-8505F and recording whether all three tones are heard and all three dishes are named. Prior work already lists this as an open hardware gap; the code path that predicts the failure is audio.dart:71 (await p.stop() on the single _ring player).

5. Coverage manifest

Every file in this stream's scope, its line count at 03a176e, and what was checked in it.

File Lines What was checked in it
lib/ui/home.dart 722 Read in full. Every gesture handler traced (_tapTile 348-401, _dup 403-418, _openEditor 420-457, _toggleEdit 459-463, _openSettings 465-492, pan handlers 500-546, _buildTile callbacks 617-665); tap counts for start/pause/reset/stop/batch/adjust derived from them; edit-mode precedence bug (F3); dupShow on ringing (F4); boot/lifecycle and failure-mode behaviour (F14); the 30 French Journal.log sites (F12); _criticalBanner 670-704 and _empty 706-721 reviewed for operator-visible state
lib/ui/tile.dart 819 Read in full. Tile tap target (372-376), batch chip position and hitbox (384, 417-468), control-row visibility and touch-floor tiers (565-597, 600-626), _CtlBtn hit padding (679-683), ringing count-up source (199-201), pie geometry and colour (281-282, 759-786), edit veil and badge (297-322)
lib/ui/modals.dart 746 Read in full. Editor tap costs (name 249, type 262-282, duration picker 392-440 incl. the +1 min / +5 s step sizes, presets 288-306, tone picker 321-331, phrase 334-344, Save/Cancel/🗑 346-359), step rows and floors (457-525), Settings surface (616-693) confirmed to contain language, volume, journal export and nothing else
lib/ui/header.dart 215 Read in full. Three buttons only (Settings / Edit / New, 96-120); responsive degradation ladder 32-38; confirmed no station filter, no search, no board-level controls
lib/ui/grid_layout.dart 109 Read in full. solve and _measure replicated arithmetically and validated against the repo's pinned expectations; board capacity computed for 4-40 dishes on two reference screens
lib/ui/theme.dart 82 Read in full. tileIdle vs track colour identity (16, 25), fillFor urgency curve (59-69), the 12 tones (45-48), the 6 duration presets (52-54), fmtTime/fmtUp (77-83)
lib/ui/logo.dart 18 Read in full. Single Image.asset; no interaction, no product surface. Nothing found
lib/engine/models.dart 160 Read in full. Full TimerDef field set confirmed to carry no station/owner/timestamp (29-49); legacyZoneId confirmed migration-only (37-39, 78); RunEntry fields incl. driftMs (101) and rangAt (99); CloneRef.batchNo (144-151); the v0.4.11 zone-removal header (6-13) read as the governing decision
lib/i18n.dart 167 Read in full. Every operator-facing string; exactly two language blocks (40, 88) and two TTS locales (166); tone labels (8-37); announcement ownership rule (152-166)
README.md 38 Read in full. Intended design, stated architecture, the three "known gaps vs spec" (30-34), and the placeholder-name decision (38)
lib/engine/engine.dart 432 Read in full (evidence file, S3's scope for correctness). Checked here for product semantics only: startTimer 170, spawnClone/maxBatch 191-200, stopTimer 215, adjustTimer 228, _fireAlarm rangAt 265-287, repeat escalation 289-296, saveDef run-kill 355-406, deleteDef 408
lib/engine/store.dart 354 Lines 180-354 read in full; 1-180 read via the code map plus targeted verification of the persistence keys (15-24). Checked: seed content 296-353, phrase-repair name keys 187-195, zone→sound migration 233-285
lib/audio/audio.dart 116 Read in full. Single _ring player and stop()-before-play() (19, 62, 71-73); pool sizing 44-48; haptic patterns 96-115
lib/audio/voice.dart 204 Lines 140-203 read in full; remainder via code map. Checked: serial queue and 300/60 ms re-drains (149-178, 201), 20 s staleness drop (180-188), per-timer cancel (191-203)
lib/alarm_backstop.dart 279 Read in full. _desired excluding ringing (99-116), diff/debounce (122-168), grace and past-deadline guard (170-195), notification title carrying the dish label (188, 259), background/foreground paths (238-278)
lib/main.dart 58 Read in full. Unconditional wakelock with no battery guard (28-32), immersive mode (33), no-text-scaling wrapper (48)
lib/audio/alarm_volume.dart 68 Read via the code map plus L4; checked only that the floor and rising-edge assertion match the prior decision. No new product finding
lib/journal.dart 250 Read via the code map; checked exportCopy (207) as the only export path and confirmed the journal is not surfaced in-app. No new product finding
lib/diagnostics.dart 54 Read via the code map; checked that critical is the only operator-visible failure channel. No new product finding
android/app/src/main/AndroidManifest.xml Read in full. Boot receiver present, exact-alarm and full-screen-intent permissions present, INTERNET absent (count 0)
pubspec.yaml Dependency list read; no network client; share_plus present and already used
test/grid_layout_test.dart 152 Read in full, used to validate the geometry replication

Scoped areas where nothing was found: lib/ui/logo.dart (18 lines, no interaction surface) and lib/audio/alarm_volume.dart (68 lines, the loudness rule is settled by L4 and behaves as documented) produced no product finding. Both were opened; the checks are listed above.

Proof artifacts: proof/01_findings/S10/grid_capacity.txt, proof/01_findings/S10/greps.txt, proof/01_findings/S10/captures/android_touch_target_size.txt.

S10 refutation — product and feature review for professional kitchensagent_reports/S10_refute.md · raw .md

S10 refutation — product and feature review for professional kitchens

Refuter: independent fresh-context agent, governing rule R5. Subject: the app repository @ 03a176e72ef0075eec86b8915cbe6e93042a3b9d (0.4.12+18). Target: findings/S10_product.md and proof/01_findings/S10/. Read-only: the pinned tree was untouched (git status --porcelain → empty at start and end). All arithmetic was replicated outside the repo, in the session scratchpad.

Method. Every gesture chain in S10's tap table was re-walked handler by handler in the source. The board geometry was re-derived from scratch (independent Python replication of GridLayout.solve, lib/ui/grid_layout.dart:62-108) rather than read from S10's proof file. Every file:line S10 cites was opened. Two decisions S10 leans on were checked against the primary record: git show 07ee62a (the zone-removal commit) and the audio.dart:14-18 header.

Headline result. S10's code facts survive almost intact — every citation resolves and every gap it names is real. What does not survive is the commercial framing built on top of them: the headline risk, the "four one-tap gestures", the 116-296 range, and four of the five sale-blockers. The stream also missed the single most consequential one-tap destructive gesture in the product.


1. Verdict per finding

# S10 claim Verdict Reasoning (evidence)
F1 Configuration exists in one copy, no export/import/backup/second-device transfer/restore SEVERITY CHANGED — BLOCKER → MEDIUM; sub-claim REFUTED The in-app half is CONFIRMED: lib/journal.dart:207 is the only export in lib/, Settings (modals.dart:616-693) carries language, volume and journal-send and nothing else. The absolute half is false. android/app/src/main/AndroidManifest.xml sets no android:allowBackup and no dataExtractionRules/fullBackupContent. Android: "Apps that target Android 6.0 (API level 23) or higher automatically participate in Auto Backup… The default value is true"; the backed-up set includes "sharedpref: the directory where SharedPreferences are stored"; data goes "to the user's Google Drive", "up to 25 MB of backup data per app user", and Auto Backup "supports cloud backups to Google Drive and direct device-to-device (D2D) transfers" — https://developer.android.com/identity/data/autobackup, retrieved 2026-08-04, capture at proof/01_findings/S10_refute/captures/android_autobackup.txt. So backup, restore and second-device transfer do exist, by platform default, for exactly the cadence-timers-v1 key S10 says is unprotected. What genuinely does not exist: in-app export/import, and any way to push a menu to a second, differently-owned tablet. Severity: R13 reserves BLOCKER for "prevents store submission or loses/corrupts a user's data or fails to ring an alarm". A missing export does none of the three.
F2 Every destructive action is one unconfirmed tap with no undo (✕, 🗑, Save-on-running, double-tap reset) SEVERITY CHANGED — BLOCKER → HIGH; "four one-tap gestures" REFUTED "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. The four gestures are verified individually in §3 below — but only one of them is one tap, and only one of them (a different one) touches the menu. See §3. Severity HIGH is carried by (c) alone: engine.dart:378-380 kills a live run and every batch of it on Save, during service.
F3 In edit mode, tapping a ringing tile opens the editor instead of silencing CONFIRMED lib/ui/home.dart:349-353 tests editing before r.status == RunStatus.ringing at :365. tile.dart:569-570 renders the ±/✕ row only for running/paused, so the tile carries no escape while ringing. editing defaults false (home.dart:40), is not persisted, and nothing auto-exits it. Severity HIGH stands.
F4 The batch chip is live on a ringing tile, in the slap zone SEVERITY CHANGED — HIGH → MEDIUM The code fact is CONFIRMED exactly as cited: home.dart:624 status != null && cnt < Engine.maxBatch does not exclude ringing, and tile.dart:384 excludes only editing. The geometry argument is weaker than stated. On the reference 602×332 tile the chip measures ≈64×34 px plus EdgeInsets.fromLTRB(2,7,7,7) (tile.dart:431) → ≈73×48 px, sitting immediately right of the name at roughly one quarter down the tile. Flutter resolves a touch to one point, not to a palm footprint, so "precisely where a flat hand lands" is an assertion, not a measurement. Consequence is also self-announcing: the mis-tap starts a pan and the original alarm keeps ringing, so the cook is told within one second. Real defect, not a HIGH one.
F5 Two dishes ringing cannot be told apart by sound; the second ring stops the first SEVERITY CHANGED — HIGH → MEDIUM; sub-claim REFUTED Code CONFIRMED: audio.dart:19 one dedicated _ring player, :62 selects it for every ringtone, :71 await p.stop() before :73 play. But "they produce whichever fired last" is wrong. The two runs carry independent repeat schedules (engine.dart:283-284 first gap 7000 ms, :293-294 ×0.72 down to a 2000 ms floor, each keyed to its own RunEntry.voiceGap/nextVoiceAt), so the tones alternate, each truncated at the other's next repeat, rather than one being suppressed. Both tones are heard inside one repeat cycle; each is cut short. The voice channel also names each dish independently (home.dart:294-295). Degradation, not the total loss described.
F6 After an outage the board understates lateness by the whole outage SEVERITY CHANGED — HIGH → MEDIUM; premise REFUTED The mechanism is CONFIRMED: engine.dart:279 sets rangAt = n, tile.dart:199-201 counts up from it, and driftMs (engine.dart:278) reaches the journal (home.dart:288-292) but is not among the fields TileView receives (tile.dart:12-34). S10's premise — "the count-up exists to tell the cook how long a dish has been sitting" — is S10's own reading, and the line it quotes 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 does stand: lateness is computed and journaled and never shown anywhere in the UI.
F7 Nothing on the board says which station or cook a timer belongs to CONFIRMED as a gap; SEVERITY CHANGED HIGH → MEDIUM; "accepted without seeing" PARTIALLY REFUTED; "re-litigating L1" REFUTED Full treatment in §4. TimerDef (models.dart:29-49) carries no station/owner/timestamp; legacyZoneId is migration-only (:37-39,78); no tile renders a station. 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, removed hunk).
F8 ±10 s per tap, impossible once ringing CONFIRMED, one sub-claim REFUTED tile.dart:569-570 and :601-616 verified; engine.dart:228-241 adds 10 s per call; no repeat-on-hold anywhere (grep above). The sub-claim that the alarm_backstop.dart:36-39 debounce now "defend[s] against a burst the UI can no longer produce" is wrong: S10's own table says +5 min costs 30 taps, and 30 taps inside 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. MEDIUM stands.
F9 Urgency is the least visible thing on the board CONFIRMED theme.dart:16 and :25 are byte-identical Color(0xFFE7DED0); tile.dart:778-779 sweeps p * 2 * math.pi, so the coloured area → 0 as the deadline arrives. MEDIUM stands, and S10 correctly names the artifact that would settle it rather than hedging.
F10 The batch cap is silent CONFIRMED engine.dart:31,194, home.dart:624. MEDIUM stands.
F11 Ships seeded with one restaurant's menu, removable one dish at a time CONFIRMED store.dart:296,326-343 verified verbatim, including const oven = 'Cascade', fryer = 'Chirp'. MEDIUM stands.
F12 No operator-visible history; the only record is a French-only diagnostic file CONFIRMED home.dart:358,368,383,396,408 are hard-coded French independent of store.lang; modals.dart:698-717 is the only surface. MEDIUM stands.
F13 Below 150 px tile width the touch-target floor is removed CONFIRMED, and understated tile.dart:589-597 sets minH = 0 in the bottom tier. Geometry replicated independently: 800×540 crosses 150 px at n=21 (tile 127×108) and 1280×740 at n=49 (tile 136×116) — identical to S10's grid_capacity.txt, and the pinned 4-dish case reproduces exactly (cols 2, tileW 602, rowH 332, gap 21, pad 27). S10's "roughly 37 px" is optimistic: at tile 127×108, btnH (tile.dart:678) = 11.49×1.2 + 2×2.59 + 2 = 21.0 px, +14 px hit padding = 35 px for ±10 s, and the ✕ (no sign, so sized on fs) = 10.37 + 5.18 + 2 = 17.6 px, +14 = 31.6 px. Both further below the 48 dp guidance than S10 reports. LOW stands.
F14 Per-failure-mode operator experience; ringing has no OS backstop CONFIRMED, incomplete alarm_backstop.dart:103-106 verified: _desired skips anything whose status is not running, and sync (:127-128) cancels the armed alarm the moment it stops matching. 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 same :104 filter also excludes paused, and the table has no paused row — see missed gap M1. HIGH stands.

Counts: 6 CONFIRMED, 7 SEVERITY CHANGED (with 5 embedded sub-claims REFUTED), 1 CONFIRMED-but-incomplete. Zero findings refuted outright — every gap S10 names exists in the code.


2. Independently derived tap counts

Same convention as S10: finger-downs on glass, keyboard characters excluded. Derived by walking each handler, from the board at rest and not in edit mode.

Flow S10 Mine Where mine differs
Start one known dish 1 1 Agree. Chain verified end to end in §5.
…same, if edit mode was left on 2 home.dart:349-351 opens the editor first. S10 gives this row for silencing but not for starting, an asymmetry in its own table.
Silence one ringing alarm 1 1 Agree (home.dart:365-374).
Add a batch 1 1 Agree.
Pause a running dish 1 1 Agree (applied after Engine.dblMs = 260 ms).
Reset a running dish 2 2 Agree.
Clear a running dish (✕) 1 1 Agree.
+1 min / +5 min 6 / 30 6 / 30 Agree.
Create a dish at a preset duration 5 4 (5 with a tone) S10's 5 counts a tone tap. kDefaultSound = 'Chirp' (models.dart:27) makes it optional: New → name field → preset chip → Save.
Create a dish at 7:30 14 12 (13 with a tone) S10 derives only from the 3:00 default (modals.dart:217), 4×▲min + 6×▲sec. Cheapest path is the 5:00 preset chip (1 tap) + 2×▲min + 6×▲sec = 9 duration taps, not 10.
Delete one dish 3 / 2 3 / 2 Agree.
Clear the 7 seed dishes 15 15 Agree.
20-dish menu on a fresh tablet 116 … 296 95 … unbounded (≈335 for a realistic menu) Three separate errors, below.

Where the 116-296 range breaks.

  1. The floor is 21 taps too high. S10 charges "16 to clear the seed", but its own table says 15, and 15 is right: Edit (1) + 7 × (tile + 🗑) (14). The extra tap is ✓ Done, which is not needed+ Nouveau (header.dart:113-120) is unconditional and is not gated on editing, so the operator creates all 20 dishes without ever leaving edit mode. With the optional tone tap dropped, the floor is 15 + 20 × 4 = 95; with a chosen tone per dish, 115.
  2. 296 is not a ceiling. It is one example (7:30) multiplied by 20. A 25:00 dish costs 1 preset tap + 10 ▲min = 11 duration taps; a 180:00 dish costs 165. For a plausible 20-dish menu bounded at 20 minutes the ceiling is 15 + 20 × 16335, and formally the figure is unbounded. Presenting 296 as "if no duration is a preset" states a bound the arithmetic does not support.
  3. Part of the duration space is unreachable at any tap count. The seconds column steps sec = (sec + 5) % 60 up and (sec + 55) % 60 down (modals.dart:436-437), so a single-mode dish can only be given a duration whose seconds are a multiple of 5. 3:07 cannot be entered at all. Chain mode, which uses free numeric fields (modals.dart:502-503), can. S10 computed a tap cost over a space that is partly not addressable and never said so — see missed gap M2.

The two flows that matter in the rush are the two S10 got right, and they are the product's strongest claim: one tap to start, one tap to silence, both immediate, both verified below.


3. The headline risk, gesture by gesture

S10's headline: a menu costing 116 to 296 taps to build exists in one unexportable copy while four one-tap gestures destroy parts of it with no confirmation and no undo.

All four gestures verified; none is confirmed; none has an undo. The framing that binds them is wrong.

# Gesture Taps to reach it from the resting board What it actually destroys Cost to undo by hand
a on a running tile — tile.dart:618-625home.dart:656-663engine.dart:215 1 The run (deadline + elapsed cook time). Definition untouched. Also dissolves the tile if it is a batch clone (engine.dart:219-223). 1 tap — restart at full duration
b 🗑 in the editor — modals.dart:348-351home.dart:431-433engine.dart:408 3 (Edit → tile → 🗑) The definition, permanently. The only one of the four that touches the menu. Re-create: 4-16 taps + typing
c Save on a dish that is running — engine.dart:375-380 3 (Edit → tile → Save) The run and every batch of it (run.remove + removeClonesOf), unconditionally, even when nothing about the timing changed 1 tap — restart at full duration
d Double-tap reset — home.dart:377-385 2, inside 260 ms The run's elapsed time nothing to undo; the dish is running again

Verdict on the headline: REFUTED as written. - "Four one-tap gestures" — exactly one is one tap (a). (d) is two taps by construction, and (b) and (c) each require three deliberate taps through a modal that displays Cancel and Save beside the destructive control (modals.dart:346-359). - "destroy parts of it", where it is the 116-296-tap menu — three of the four destroy no configuration at all. (a), (c) and (d) destroy run state, which costs one tap to recreate. Only (b) touches the menu, and it is the three-tap one. - The two halves of the sentence do not connect. The expensive artifact (the menu) is reachable only by the most deliberate gesture; the cheap gesture (✕) destroys the cheapest thing on the board.

What survives, and it is the real finding: (c). Pressing Save on a dish whose name, tone or phrase changed but whose timing did not still executes run.remove(t.id) and removeClonesOf(t.id) (engine.dart:378-380), and the edit-mode veil deliberately keeps the digits ticking underneath (tile.dart:297-304), so the operator watches a live timer and loses it by saving. That is defect repair, in scope under R6, and S10 correctly names the test that would prove it.


4. The station-ownership question

The record. L1 is lib/engine/models.dart:6-13 plus commit 07ee62a. Its reasoning is entirely about where the sound is edited: three toolbar buttons for overlapping functions, one intention split across two screens, "Zone" an internal structural term promoted to a toolbar button. The stated accepted cost is an editing cost — «une station de trois plats se regle en trois gestes au lieu d'un».

What the commit says about the tile. One sentence, and S10 never quotes it:

«La pastille de couleur disparait des tuiles ; elle ne laisse aucun trou, le camembert etant colore par le temps restant et non par la zone.» (The colour pill disappears from the tiles; it leaves no hole, since the pie is coloured by remaining time and not by the zone.)

What was actually deleted. git show 07ee62a -- lib/ui/tile.dart, removed hunk inside _topGroup:

-      if (z != null)
-        Container(
-          margin: EdgeInsets.only(top: 0.6 * ch + 0.8 * ch),
-          padding:
-              EdgeInsets.symmetric(vertical: 0.5 * ch, horizontal: 2 * cw),
-          decoration: BoxDecoration(
-              color: Color(z.color), borderRadius: BorderRadius.circular(999)),
-          child: Text(
-            z.name.toUpperCase(),

The widget printed z.name.toUpperCase() — the station's name, in text, on every tile. The commit describes it as la pastille de couleur and justifies its removal on a colour-redundancy argument: the pie is already coloured by time, so the colour left no hole. That argument is sound about the colour and says nothing about the name.

Verdict, three parts.

  1. Is S10-F7 re-litigating L1? NO. L1's reasoning is about the sound's editing location. A station label printed on the tile leaves the sound on the timer, adds no second screen, and restores none of the split intention L1 removed. Nothing in L1's record argues against a station signal; it argues against a zone object owning the ringtone. S10 was right not to treat this as closed, and right to reject option (a) (bringing zones back) as refuted by L1.
  2. Was the consequence "accepted without seeing"? PARTIALLY REFUTED. The team saw the badge leave the tile and wrote a justification for it. So it was not invisible. But the justification they wrote covers only what they called it — a colour — and the artifact they deleted also carried a name. The colour's departure was reasoned about and is defensible; the name's departure is undocumented. That is a sharper and better-evidenced statement of the gap than S10's, and it is the finding this stream should carry.
  3. Does it block a sale? NO — see §5. Two-station kitchens already tell stations apart by the one thing on the tile that is always there: the dish name the operator typed. "A ringing alarm is addressed to nobody" is contradicted by the ringing tile itself, which prints the dish name in red (tile.dart:212-215, 401-403) while the voice announces it (home.dart:294-295). Severity HIGH → MEDIUM; it is a strong roadmap item, not a blocker.

5. Does "5 gaps block a sale" stand?

S10's own test: "a competent buyer evaluating this against the alternative of shouting and a phone timer would decline, or would decline to roll it past one tablet." The buyer S10 names throughout is a restaurant with one Android tablet.

# Gap Code fact Blocks the sale?
1 No config export/import/backup CONFIRMED for in-app export/import; REFUTED for backup / restore / second-device transfer (Android Auto Backup, §1 F1) REFUTED for the single-site buyer — a buyer with one tablet has nothing to export to, and the dropped-tablet case is partly covered by the platform. CONFIRMED for a multi-site rollout, which is a different sale. S10's own consequence column already says so: "A group cannot roll out a standard menu."
2 Every destructive action one unconfirmed tap, no undo CONFIRMED that nothing confirms and nothing undoes; REFUTED that four one-tap gestures destroy the menu (§3) REFUTED. The only gesture that destroys configuration costs three deliberate taps through a modal showing Cancel and Save. A commis leaning on the board destroys, at worst, a run — one tap to recreate. (What a commis leaning on the board does do is worse than S10 realised, and S10 missed it: M1.)
3 No station identity CONFIRMED (§4) REFUTED. Dishes are identified by the operator's own names on every tile, in red while ringing, spoken aloud. Twelve per-dish tones exist. Real gap, not a decline.
4 iOS has no OS-level safety net CONFIRMED — alarm_backstop.dart:72-76 passes Android-only InitializationSettings, :92-96 leaves _ready false, :123 makes every scheduling path return REFUTED as a sale-blocker; CONFIRMED as a ship-blocker for a platform that has no build. There is no iOS product to decline: pubspec.yaml:41 ios: false in flutter_launcher_icons; the baseline records no Xcode and no CocoaPods; L7 records AppDelegate.swift as «JAMAIS COMPILE». A gap on an uncompiled platform blocks the decision to open that platform, not a sale in front of you.
5 French and English only CONFIRMED — i18n.dart:40,88 two blocks; :166 ttsLocale returns only fr-FR/en-US; :148-150 readyPhrase hard-codes two grammars REFUTED. The localised surface is 37 chrome strings behind three header buttons and two modals. Everything a cook reads during service is operator-typed (dish names) plus digits. A Spanish or Italian kitchen runs the board in its own language with English chrome. The residue is real but narrow: a Spanish dish name is pronounced by an en-US voice. It closes markets needing RTL or non-Latin chrome, not "the Gulf and most US kitchens" — US kitchens are served by the English block.

Verdict: "5 gaps block a sale" does NOT stand. Zero of the five block the sale S10 frames. One (gap 1) blocks a different sale — the multi-site rollout — and should be re-stated that way. All five are real gaps and belong in the report; none of them is a reason a single-site restaurant with one Android tablet declines to buy.


6. Gaps S10 missed

Each is grounded in a code path and is itself refutable (R2, R5).

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

  • Severity: HIGH
  • Location: lib/ui/home.dart:387-399, lib/engine/engine.dart:243-251, lib/alarm_backstop.dart:104-106 and :127-128
  • What is wrong: 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 dish has no deadline and the engine will never fire it. The mutation then routes through host.persistRun()home.dart:252-258backstop.sync, where _desired (:104) admits only runs whose status is running — so the scheduled OS alarm for that dish is cancelled immediately at :127-128 ("a timer no longer running loses its net now"). A paused dish is therefore the one state with neither an in-app deadline nor an OS backstop, it is reachable by one unconfirmed tap, there is no undo, and nothing in the product ever escalates or re-surfaces a dish left paused. The audible feedback is host.onClick(false) (engine.dart:250) — the identical down-click that stopping a timer plays (engine.dart:224), so ear alone cannot tell pause from stop.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: this is the answer to the question S10 asked and answered wrongly — "what happens when a commis leans on the board?" Not a lost menu: a silently disarmed dish. It is the exact failure the product exists to prevent, reachable by the cheapest possible mistake, and S10-F14's seven-row failure table has no row for it.
  • Proposed fix: 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.
  • How to prove the fix: 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.

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

  • Severity: MEDIUM
  • Location: lib/ui/modals.dart:436-437, against lib/ui/modals.dart:502-503
  • What is wrong: the seconds column of the duration picker steps (sec + 5) % 60 up and (sec + 55) % 60 down, and the six presets (theme.dart:52-54) are all multiples of five. There is no text entry for a single-mode duration. So 3:07 — or any duration whose seconds are not 0/5/10/…/55 — cannot be entered at all for a single-duration dish. A chained dish can: _stepRow gives every phase a free numeric field clamped only to 0-59 (modals.dart:502-503, 520-525). The two modes disagree about what a duration is.
  • Evidence: 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)),
  • Why it matters for a restaurant kitchen: every seeded dish happens to be a multiple of five (45, 135, 260, 375, 210, 720 — store.dart:327-332), so the constraint is invisible until a kitchen writes down its own time. A cook who times a dish at 2:42 discovers the board will not accept it, in the editor, with no message. It also invalidates part of S10's tap arithmetic: the "296 if no duration is a preset" figure prices a space the picker cannot reach.
  • Proposed fix: 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.
  • How to prove the fix: a widget test asserting the seconds readout can reach 7 from 0 — red today at any tap count.

M3 — The dish name is silently capped at 24 characters with the counter switched off

  • Severity: LOW
  • Location: lib/ui/modals.dart:251-252
  • What is wrong: maxLength: 24 with .copyWith(counterText: ''). The field stops accepting keystrokes at 24 characters and shows nothing to say why — the character counter Flutter would normally render is explicitly blanked. The cap is not a layout constraint: the tile deliberately auto-shrinks long names rather than truncating them (tile.dart:393-395, "un nom coupé ne sert à rien en cuisine"), so the display would have absorbed a longer name.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: "Côtelettes d'agneau grillées" is 28 characters. The operator types, the field stops, and nothing explains it. Small, cheap to fix, and the kind of thing a head chef notices in the first thirty seconds — the same window S10-F11 argues decides adoption.
  • Proposed fix: show the counter (drop counterText: '') or raise the cap. Either is a one-token change; which one is Serge's call.
  • How to prove the fix: a widget test entering 30 characters and asserting the controller holds 30 — red today.

M4 — Android Auto Backup is on by default and is nowhere in the product's own account of its data

  • Severity: MEDIUM
  • Location: android/app/src/main/AndroidManifest.xml (no android:allowBackup, no android:dataExtractionRules, no android:fullBackupContent)
  • What is wrong: the app ships with Android Auto Backup enabled by omission, which means SharedPreferences — the kitchen's entire menu and the operator's language and volume settings — is uploaded to the tablet owner's Google Drive and restored onto any device that signs in during setup. Nothing in the repo states this, no privacy text mentions it, and S10-F1 asserts the opposite. It cuts both ways commercially: it is the answer to "what if the tablet dies" that the sales conversation currently does not know it has, and it is an undeclared off-device copy of customer data that a store privacy declaration will have to account for.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: it converts S10's stated worst case — a dropped tablet costs 116-296 taps — into a conditional one. It is also load-bearing for the store submission stream (S11/S12): an app that silently backs up user data to Google Drive is a fact a data-safety form has to declare.
  • Proposed fix: set android:allowBackup explicitly, in whichever direction Serge chooses, per the platform's own recommendation. Compliance plumbing, in scope under R6.
  • How to prove the fix: grep the built manifest for the attribute; it is absent today.

7. R6 enforcement on S10's output

R6 reads: "No new end-user features… If the business needs a feature, REPORT it with a spec." Reporting a feature with a spec is therefore sanctioned, and F1's and F7's clearly-labelled "reported option, out of scope to implement" blocks are compliant, not drift. Three places do drift, all of them in the opposite direction — a product decision presented as an in-scope repair.

Where Drift Why it is drift
S10-F5 proposed fix "route ringtones through the existing 4-player pool keyed by timer id… this is plumbing, not a new capability" It is the capability. Two dishes audible at once is exactly what the product cannot do today, and the change reverses a written decision (audio.dart:14-18: the ring got its own player because pool round-robin layered a repeat over the tone still sounding). The reason given there applies to the proposed pool too — that pool is shared with clicks and step chimes (audio.dart:45-47, 89-92), so four live sounds exhaust it. Report as a feature with a spec; do not label it plumbing.
S10-F6 proposed fix "the count-up base could be endsAt-derived rather than rangAt-derived" Inverts the decision written verbatim on the line S10 quotes (engine.dart:279). Changing what the biggest number on a tile means during service is a product decision, not a display correction. The in-scope repair is surfacing driftMs, which is already computed and thrown away.
S10-F4 proposed fix "exclude ringing from dupShow… it removes no capability, since batching a dish that is ringing is not a stated flow" Removing a reachable behaviour on the grounds that it is undocumented is a product decision. Defensible, and probably right — but it should be reported as a decision, not asserted as costless.

Two smaller notes. S10's tap table gives an edit-mode row for silencing and none for starting, which is the asymmetry §2 corrects. And S10-F8's claim that the backstop debounce now guards a burst "the UI can no longer produce" contradicts S10's own 30-tap row two lines above it.


8. Coverage manifest for this refutation

File / artifact Lines What I checked in it, independently of S10
lib/ui/home.dart 722 Read in full. _tapTile 348-401 walked branch by branch (edit / idle / ringing / running-paused, incl. the 260 ms _tapPending window); _dup 403-418; _openEditor 420-457 incl. the delete branch 431-433; _toggleEdit 459-463; persistRun 252-258 traced into backstop.sync; _buildTile 617-665 and dupShow 624; lifecycle 174-204; _criticalBanner 670-704
lib/ui/tile.dart 819 Read in full. Root GestureDetector 372-376 and hit behaviour; _topGroup 381-471 incl. chip hitbox 427-431 and measured chip geometry; _controls 565-640 with all three width tiers; _CtlBtn.btnH 677-678 recomputed at the bottom-tier tile size; ringing count-up 199-201; pie sweep 759-786; edit veil 297-322
lib/ui/modals.dart 746 Read in full. Editor 244-361: name field cap 249-252, mode chips 262-282, duration picker 392-440 with the ±1 min / ±5 s step arithmetic re-derived, presets 288-306, tone picker 321-331, Save/Cancel/🗑 346-359; _save floors 363-390; step rows 457-525; Settings 607-693 confirmed to hold language, volume, journal-send only
lib/ui/header.dart 215 Read in full. Confirmed onNew (113-120) is not gated on editing — the fact that removes one tap from S10's setup total
lib/engine/engine.dart 432 Read in full. startTimer 170-187, spawnClone 191-200, stopTimer 215-226, adjustTimer 228-241, pauseTimer/resumeTimer 243-263, _fireAlarm 265-287 (drift + rangAt), _alarmRepeat 289-296, tick 300-345, saveDef 355-406, deleteDef 408-416
lib/engine/models.dart 160 Read in full. Full TimerDef surface 29-49; legacyZoneId 37-39, 78; header comment 6-13 read as the L1 record and compared against commit 07ee62a
lib/engine/store.dart 286-354 Seed and seedIfFresh read in full; the seven seeded durations checked against M2
lib/alarm_backstop.dart 60-190 init 69-97 (Android-only settings), _desired 99-116, sync 122-149, _flushSchedules 154-168, _schedule 177-190
lib/audio/audio.dart 116 Read in full. _ring 19, _play 58-78, pool 44-48, assetFor 83-84
lib/audio/voice.dart 140-203 enqueue/_drain/_dropStale/stopFor — the serialisation and 20 s staleness claims in S10-F5
lib/i18n.dart 140-167 call, readyPhrase, announcementFor, ttsLocale — the basis for the gap-5 verdict
lib/ui/theme.dart 82 Read in full. tileIdle vs track identity, pausedFill/pausedText, fillFor, presets, tones
lib/ui/grid_layout.dart 109 Read in full and replicated arithmetically in Python (scratchpad), validated against test/grid_layout_test.dart:110-118, then run at n=12/16/20/21/24 on 800×540 and n=20/40/48/49 on 1280×740
android/app/src/main/AndroidManifest.xml Read in full. Absence of allowBackup / dataExtractionRules / fullBackupContent confirmed; INTERNET absent; boot receiver and alarm permissions present
android/app/build.gradle.kts Read in full. Release still signed with the debug config
pubspec.yaml Dependency list; flutter_launcher_icons: ios: false
test/grid_layout_test.dart 1-40, 100-125 Confirmed boardW/boardH = 1280/740 is "la zone sous le bandeau", so S10's geometry inputs are the board area, not the screen
git show 07ee62a Full commit message + the lib/ui/tile.dart diff hunk that removed the zone-name pill
proof/01_findings/S10/greps.txt, grid_capacity.txt Every grep re-run; every geometry row re-derived
Android Auto Backup documentation Fetched with utilities/chrome.py, stored at proof/01_findings/S10_refute/captures/android_autobackup.txt

Not re-examined: lib/main.dart, lib/journal.dart, lib/diagnostics.dart, lib/audio/alarm_volume.dart, lib/ui/logo.dart. S10's findings touch these only through claims already settled by prior work (L4, L13) or by the code map, and I found no product claim in S10 resting on them that required independent verification.


9. Summary

  • 6 CONFIRMED, 7 SEVERITY CHANGED, 1 CONFIRMED-but-incomplete. No S10 finding is refuted outright — every gap it names exists in the code, and every file:line resolves at 03a176e.
  • 5 embedded sub-claims refuted, all of them load-bearing for the commercial argument: "no backup anywhere" (F1), "four one-tap gestures destroy the menu" (F2), "whichever fired last" (F5), "the count-up exists to show how long a dish has been sitting" (F6), "the debounce guards a burst the UI can no longer produce" (F8).
  • "5 gaps block a sale" does not stand. Zero block the sale S10 frames; one (no export) blocks a multi-site rollout and should be restated as that.
  • 4 missed gaps contributed, one of them (M1) more consequential than several S10 reported.
  • 3 R6 drifts flagged, all of the form product decision presented as in-scope repair — the opposite of the drift the brief anticipated. F1's and F7's feature specs are R6-compliant, because R6 explicitly sanctions "REPORT it with a spec".

Stream S11: finding and refutation

S11 — Asset licensing and intellectual-property provenancefindings/S11_asset_licensing.md · raw .md

S11 — Asset licensing and intellectual-property provenance

Subject: the app repository at 03a176e72ef0075eec86b8915cbe6e93042a3b9d, version 0.4.12+18. Read-only stream (R10) — nothing under cadence-app was written, staged, or checked out.

Raw evidence: proof/01_findings/S11/ (14 recorded runs; runs 1-11 reproduce end to end with sh proof/01_findings/S11/run_s11_proofs.sh [not published], run 13 with sh proof/01_findings/S11/alarm_probe.sh [not published]). Store-policy captures: proof/03_market/captures/S11_apple_review_guidelines.txt, proof/03_market/captures/S11_play_ip_policy.txt.

Draft remedy artifact: findings/S11_LICENSES_draft.md.


0. Headline

Verdict Count Assets
GENERATED 13 the 12 tones in assets/audio/ + android/app/src/main/res/raw/cadence_alarm.wav
THIRD-PARTY 12 7 TTF fonts + 5 web/ icons
AUTHOR-ORIGINAL 4 3 launcher-icon PNGs + assets/logo/mark_white.png
UNKNOWN 3 assets/audio/step.wav, click-up.wav, click-down.wav
Total binary assets in scope 32

No bundled font's licence blocks commercial app-store distribution. All three are SIL Open Font License 1.1, which explicitly permits bundling, embedding and selling with software. All three condition that permission on one thing the repo does not do: shipping the copyright notice and the licence text with each copy. No embedded copyright contradicts its assumed licence.


1. Per-asset provenance table

"Entered repo" is the first commit that added the file (git log --diff-filter=A); full history where the file was later rewritten is in the notes column. Evidence: proof/01_findings/S11/02_asset_inventory.txt.

1.1 Fonts — assets/fonts/

File Bytes Format Entered repo Claimed origin Verdict
BigShouldersDisplay-Medium.ttf 68,592 TrueType, 16 tables 22902e0 2026-07-23 "Cadence v0.2.0 — app Flutter…" none stated anywhere in repo THIRD-PARTY — Big Shoulders Display v2.002, XO Type Co / Patric King, via Google Fonts
BigShouldersDisplay-Bold.ttf 68,456 TrueType, 16 tables 22902e0 2026-07-23 none stated THIRD-PARTY — same family
BigShouldersDisplay-ExtraBold.ttf 68,628 TrueType, 16 tables 22902e0 2026-07-23 none stated THIRD-PARTY — same family
ChivoMono-Regular.ttf 59,412 TrueType, 16 tables 22902e0 2026-07-23 none stated THIRD-PARTY — Chivo Mono v1.008, Omnibus-Type / Hector Gatti, via Google Fonts
ChivoMono-Medium.ttf 59,404 TrueType, 16 tables 22902e0 2026-07-23 none stated THIRD-PARTY — same family
ChivoMono-Bold.ttf 59,356 TrueType, 16 tables 22902e0 2026-07-23 none stated THIRD-PARTY — same family
DSEG7Classic-Bold.ttf 23,040 TrueType, 14 tables, FontForge 22902e0 2026-07-23 none stated THIRD-PARTY — DSEG7 Classic v0.46, keshikan; byte-identical to the official v0.46 release archive

1.2 Audio — assets/audio/ (15 files) and the Android raw resource (1 file)

Every one of the 13 GENERATED rows was reproduced byte-for-byte by running tools/build_ringtones.py into a clean sandbox tree and comparing SHA-256 (proof/01_findings/S11/04_ringtone_regen.txt).

File Bytes Format Entered repo Generator Verdict
beep.wav 30,914 RIFF PCM 16-bit mono 44.1 kHz 22902e0 2026-07-23, rewritten 0c17267 2026-07-24 (v0.4.4) build_ringtones.py:94 GENERATED
ping.wav 52,964 same 22902e0, rewritten 0c17267 build_ringtones.py:97-98 GENERATED
bell.wav 97,064 same 22902e0, rewritten 0c17267 build_ringtones.py:106 GENERATED
chime.wav 82,952 same 22902e0, rewritten 0c17267 build_ringtones.py:110-111 GENERATED
marimba.wav 87,356 same 22902e0, rewritten 0c17267 build_ringtones.py:118 GENERATED
buzz.wav 61,784 same 22902e0, rewritten 0c17267 build_ringtones.py:126 GENERATED
chirp.wav 75,182 same 8461639 2026-07-28 (v0.4.10) build_ringtones.py:197-198 GENERATED
coin.wav 206,870 same 8461639 2026-07-28 build_ringtones.py:202-203 GENERATED
fanfare.wav 192,316 same 8461639 2026-07-28 build_ringtones.py:209-210 GENERATED
pop.wav 124,400 same 8461639 2026-07-28 build_ringtones.py:215 GENERATED
cascade.wav 366,956 same 8461639 2026-07-28 build_ringtones.py:223-224 GENERATED
bowl.wav 308,744 same 8461639 2026-07-28 build_ringtones.py:228-229 GENERATED
android/app/src/main/res/raw/cadence_alarm.wav 142,928 same f47f2e5 2026-07-23 (v0.3.0), rewritten 0c17267 2026-07-24 build_ringtones.py:232-236 GENERATED
step.wav 54,728 same 22902e0 2026-07-23, never rewritten none in repo UNKNOWN (proven synthetic, generator absent — §3.2)
click-up.wav 28,268 same 22902e0 2026-07-23, never rewritten none in repo UNKNOWN (proven synthetic, generator absent — §3.2)
click-down.wav 28,268 same 22902e0 2026-07-23, never rewritten none in repo UNKNOWN (proven synthetic, generator absent — §3.2)
File Bytes Format Entered repo Claimed origin Verdict
assets/icon/ic_foreground.png 14,073 PNG 1024×1024 RGBA 22902e0 2026-07-23 pubspec.yaml:33-34 — "Icône launcher générée depuis l'artwork Photoshop de Serge (master : P/topics/timer-app/logo/)" AUTHOR-ORIGINAL
assets/icon/ic_legacy.png 21,103 PNG 1024×1024 RGB 22902e0 2026-07-23 same comment AUTHOR-ORIGINAL
assets/icon/ic_monochrome.png 10,517 PNG 1024×1024 RGBA 22902e0 2026-07-23 same comment AUTHOR-ORIGINAL
assets/logo/mark_white.png 8,089 PNG 238×384 RGBA 22902e0 2026-07-23 none stated AUTHOR-ORIGINAL — same artwork family as the icons (§5)

1.4 Web platform assets — web/

File Bytes Format Entered repo Verdict
web/favicon.png 917 PNG 16×16 RGBA 22902e0 2026-07-23 THIRD-PARTY — byte-identical to Flutter SDK 3.44.8 template packages/flutter_tools/templates/app/web/favicon.png.copy.tmpl
web/icons/Icon-192.png 5,292 PNG 192×192 RGB 22902e0 2026-07-23 THIRD-PARTY — byte-identical to the same template tree
web/icons/Icon-512.png 8,252 PNG 512×512 RGB 22902e0 2026-07-23 THIRD-PARTY — byte-identical to the same template tree
web/icons/Icon-maskable-192.png 5,594 PNG 192×192 RGBA 22902e0 2026-07-23 THIRD-PARTY — Flutter logo, Flutter SDK template asset (visual identity; the template file is stored as a zero-byte placeholder upstream so a byte comparison is unavailable)
web/icons/Icon-maskable-512.png 20,998 PNG 512×512 RGBA 22902e0 2026-07-23 THIRD-PARTY — same

1.5 Tooling

File Lines Entered repo Verdict
tools/build_ringtones.py 238 0c17267 2026-07-24 (v0.4.4) AUTHOR-ORIGINAL — numpy-only synthesis, no external input file, no third-party sample

2. Licence determination for the three font families

Every licence text below was fetched from the copyright holder's own published distribution and is stored under proof/01_findings/S11/. Retrieval date for all four fetches: 2026-08-04.

The three families share one licence, so the four questions have one answer set, with per-family differences called out.

Question Big Shoulders Display Chivo Mono DSEG7 Classic
Licence SIL OFL 1.1 SIL OFL 1.1 SIL OFL 1.1
Embedding in a commercial app sold through app stores? Yes Yes Yes
Attribution required? Yes — copyright notice must accompany each copy Yes Yes
Licence text must be distributed? Yes Yes Yes
Copyleft / reciprocal? On the font only, not on the app same same
Reserved Font Name? none declared none declared "DSEG" — bundled copy is unmodified so the name is kept lawfully

The operative clause, verbatim from the licence published with all three families (proof/01_findings/S11/bsd_OFL.txt, chivomono_OFL.txt, dseg046_DSEG-LICENSE.txt — the licence body is byte-identical in all three):

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

and the sanction:

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

The reciprocal obligation is scoped to the font, not to the app that embeds it:

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

So: Cadence's own source code and artwork are unaffected by the OFL. Only the seven .ttf files must stay under the OFL, and the notice must travel with them.

2.1 Big Shoulders Display

  • Source of truth: https://raw.githubusercontent.com/google/fonts/main/ofl/bigshouldersdisplay/METADATA.pb (retrieved 2026-08-04) — license: "OFL", designer: "Patric King", source.repository_url: "https://github.com/xotypeco/big_shoulders". Licence text at .../ofl/bigshouldersdisplay/OFL.txt, first line Copyright 2019 The Big Shoulders Project Authors (https://github.com/xotypeco/big_shoulders).
  • Identity of the bundled binaries — verified, not assumed. The upstream master is a variable font (BigShouldersDisplay[wght].ttf). Instancing it at each bundled weight and comparing against the bundled statics (proof/01_findings/S11/08_bigshoulders_upstream.txt):

wght=500 Medium: cmap codepoints in both=718 (vf=718, bundled=718), advance-width identical=718, max glyph bbox delta=1.000 font units (upem=2000) wght=700 Bold: cmap codepoints in both=718 (vf=718, bundled=718), advance-width identical=718, max glyph bbox delta=0.900 font units (upem=2000) wght=800 ExtraBold: cmap codepoints in both=718 (vf=718, bundled=718), advance-width identical=718, max glyph bbox delta=0.935 font units (upem=2000)

Identical character coverage, identical advance widths on all 718 characters, and glyph outlines that agree to within 1 part in 2000 of the em. These are Big Shoulders Display v2.002, not a redraw. - Embedded copyright agrees with the licence. name ID 0 is Copyright 2019 The Big Shoulders Project Authors (https://github.com/xotypeco/big_shoulders) and name ID 14 is https://scripts.sil.org/OFL. OS/2.fsType = 0 — "Installable Embedding", the least restrictive setting. - Supply-chain note (not a licence problem). fonts.google.com no longer serves this family: https://fonts.google.com/download/list?family=Big%20Shoulders%20Display returns {"error": "Unable to find family: Big Shoulders Display"}. It was superseded by the family "Big Shoulders" (ofl/bigshoulders/METADATA.pb, date_added: "2025-02-06"). The retired family is still in the google/fonts repository, which is where the licence above was fetched from.

2.2 Chivo Mono

  • Source of truth: https://raw.githubusercontent.com/google/fonts/main/ofl/chivomono/METADATA.pb (retrieved 2026-08-04) — license: "OFL", designer: "Omnibus-Type", source.repository_url: "https://github.com/Omnibus-Type/Chivo". Licence text at .../ofl/chivomono/OFL.txt, first line Copyright 2019 The Chivo Project Authors (https://github.com/Omnibus-Type/Chivo).
  • Identity of the bundled binaries — verified. Compared against the current official static instances served by https://fonts.google.com/download/list?family=Chivo%20Mono (proof/01_findings/S11/07_chivomono_upstream.txt):

A: gf_ChivoMono-Regular.ttf glyphs: 825 cmap: 642 upem: 1000 B: .../assets/fonts/ChivoMono-Regular.ttf glyphs: 800 cmap: 642 upem: 1000 codepoints A-only: 0 B-only: 0 common: 642 outline+width IDENTICAL for 642/642 common codepoints; DIFFERENT for 0

Same for Medium and Bold. Every mapped glyph is outline-for-outline and width-for-width the official Chivo Mono v1.008. - Embedded copyright agrees with the licence. name ID 0 is Copyright 2018 The Chivo Project Authors (https://github.com/Omnibus-Type/Chivo). The published OFL.txt says Copyright 2019. A one-year difference between the binary and the licence file is an upstream inconsistency, not a contradiction of licence type — both name the same copyright holder and the same licence. name ID 14 is https://scripts.sil.org/OFL. OS/2.fsType = 0 — "Installable Embedding". - Trademark. The official release carries name ID 7 Chivo is a trademark of Omnibus-Type. The trademark is on the typeface name, not on text set in it; naming the font in a credits screen is nominative use and is what OFL clause 2 asks for.

2.3 DSEG7 Classic

  • Source of truth: the designer's own repository and release, https://github.com/keshikan/DSEG, release v0.46 (published 2020-03-15), asset fonts-DSEG_v046.zip (retrieved 2026-08-04). Licence file DSEG-LICENSE.txt inside that archive is SIL OFL 1.1.
  • Identity of the bundled binary — byte-exact. proof/01_findings/S11/06_dseg_upstream.txt:

d16181c4eeae29e695ca547ee9be437d01d4909ac1f8f349f04176dc6858873b fonts-DSEG_v046/DSEG7-Classic/DSEG7Classic-Bold.ttf d16181c4eeae29e695ca547ee9be437d01d4909ac1f8f349f04176dc6858873b .../assets/fonts/DSEG7Classic-Bold.ttf RESULT: BIT-IDENTICAL

This is the Original Version, not a Modified Version. The Reserved Font Name "DSEG" is therefore used lawfully (OFL clause 3 restricts the name only for Modified Versions). - Embedded copyright agrees with the licence, and the binary carries the licence text. name ID 13 of this file contains the entire OFL 1.1 text, headed Copyright (c) 2018, keshikan (http://www.keshikan.net), with Reserved Font Name "DSEG". name ID 9 is Keshikan(Twitter:@keshinomi_88pro), ID 12 http://www.keshikan.net, ID 14 http://scripts.sil.org/OFL. - Copyright-year inconsistency across the author's own sources. The binary's name ID 13 says 2018; DSEG-LICENSE.txt inside the same v0.46 archive says Copyright (c) 2017, keshikan (http://www.keshikan.net); the current file in the repository's master branch says Copyright (c) 2020, keshikan (https://www.keshikan.net). All three name the same holder and the same licence. The notice reproduced in the remedy is the binary's own, since that is the copy actually redistributed. - OS/2.fsType = 8 — "Editable Embedding". This is Microsoft's advisory embedding field, and its value permits embedding. It does not restrict anything the OFL grants.

No. All seven binaries point at the SIL OFL and name a copyright holder consistent with the family's published licence. Two discrepancies exist and neither is a contradiction: the Chivo Mono binary's copyright year (2018) differs from the family's OFL.txt (2019), and DSEG's year differs across three of the author's own files (2017 / 2018 / 2020). Full dump: proof/01_findings/S11/03_font_name_tables.txt.


3. Audio provenance

3.1 The 13 generated files — proven by reproduction, not by reading

tools/build_ringtones.py was copied into an empty sandbox tree (the script resolves its output paths relative to its own location, so it writes into the sandbox, never into cadence-app) and run under the workspace interpreter. Every output was then SHA-256-compared against the committed file. All 13 match exactly (proof/01_findings/S11/04_ringtone_regen.txt):

IDENTICAL  assets/audio/beep.wav      7bf4f551cf976e23096c8cc08b38e2adf9f9792376b86fbec9c42ca20d990837
IDENTICAL  assets/audio/bell.wav      3f0515bade72c315a1b70cdfe716c49cc01d98476c49a12530d4518df2bfab10
IDENTICAL  assets/audio/bowl.wav      99f312c369340a4d994648d086b487a4010a2de792fd95590c79d2a565ee917e
IDENTICAL  assets/audio/buzz.wav      6a49e13b742c33fc903b62feb42ebba2b98b7a1c9789e2f4a783962db0bc6507
IDENTICAL  assets/audio/cascade.wav   959cb9eae695eadcd13790780a73a6aee6e0ac7e1c76722748b822fcc227a563
IDENTICAL  assets/audio/chime.wav     b3074633dfdafc644705a5e234b6318dc61b46360a13c9767210fd07140dd11d
IDENTICAL  assets/audio/chirp.wav     2b5a496c3cd56f8fdb02f054f60869843af0161142286b5a1a2226144079fad3
IDENTICAL  assets/audio/coin.wav      5d6523661b95793f2da72be4e67f44931c24662ba91463aa42e30268a78f154b
IDENTICAL  assets/audio/fanfare.wav   850ec62b40fc5653a2e294ae502ef8a736fb930a39df2bbd27cb2c11d441166c
IDENTICAL  assets/audio/marimba.wav   7fccf12125ce6a521940b2f5aec108c12ee38bf02e036b6de5937d0659b36174
IDENTICAL  assets/audio/ping.wav      4ebf38cea61d9d365b82ce5996a0a478c38c8393da61987eb0f2bf4583099b76
IDENTICAL  assets/audio/pop.wav       87032168938a81954fe965c4330e4cc0736b1c2fe4fdd4473603b38fab2f6e1c
IDENTICAL  android/app/src/main/res/raw/cadence_alarm.wav  f32956f65b33858c18e3dcdb1d745165c1ee6b2a8c2a3349d0c63008ffbe5df0

Reproduction is stronger evidence than reading the source, but the source corroborates it: the script's only imports are wave, os and numpy (tools/build_ringtones.py:44-45), it opens files only in write mode (wave.open(path, 'wb') at lines 87 and 181), and every waveform is built from np.sin, np.sign, np.exp, np.cumsum and np.linspace. There is no sample, no external audio file, no network call. No sound library or sample-pack licence applies to any of these 13 files.

3.2 The three files with no generator — step.wav, click-up.wav, click-down.wav

These three are the only WAVs in the repo that were added at 22902e0 and never rewritten: their blobs at 22902e0 and at 03a176e have the same SHA-256, whereas the other six v0.2.0 tones were rewritten at 0c17267 when build_ringtones.py entered the tree. That is exactly the signature of files the script does not produce.

What the audio itself proves (proof/01_findings/S11/05_ungenerated_wavs.txt):

assets/audio/step.wav
  frames=27342  duration=0.620000 s  (frames/sr exact? yes)
  leading zero samples=4  trailing zero samples=11026  exact-zero samples=11279 (41.3%)
  spectral flatness = 0.000001
  top spectral peaks (Hz): 987.1, 988.7, 985.5, 990.3, 983.9, 991.9

assets/audio/click-up.wav
  frames=14112  duration=0.320000 s  (frames/sr exact? yes)
  trailing zero samples=11025  exact-zero samples=11025 (78.1%)
  top spectral peaks (Hz): 1250.0, 1246.9, 1253.1, 1243.8, 1256.2, 1240.6

assets/audio/click-down.wav
  frames=14112  duration=0.320000 s  (frames/sr exact? yes)
  trailing zero samples=11025  exact-zero samples=11025 (78.1%)
  top spectral peaks (Hz): 818.8, 821.9, 815.6, 825.0, 812.5, 828.1

Four independent markers rule out a recording or a sampled library asset:

  1. Machine-exact durations — 0.620000 s and 0.320000 s to the sample. A trimmed recording lands on a round number by accident roughly never.
  2. Silent tails of exact zero — 11,025 samples (0.25 s) of literal 0, not a decaying noise floor. Recorded audio always has a floor.
  3. click-up and click-down are the same waveform at two pitches — identical frame count, identical peak (0.239990), identical RMS (0.028446), identical 5 ms envelope arrays, different fundamental (1250 Hz vs 818.8 Hz). Two takes of a physical click cannot match to the sample.
  4. Pure odd-harmonic seriesclick-up's partials sit at 1250, 3750, 6250, 8750, 11250, 13750 Hz with relative amplitudes 1.000, 0.334, 0.200, 0.143, 0.111, 0.091, i.e. 1×, 3×, 5×, 7×, 9×, 11× at 1/n. That is the textbook Fourier series of a square wave, produced by an oscillator.

Verdict: UNKNOWN. The origin is not establishable from the repository — no generator, no comment, no README line, no commit message names them. What is established is narrower and still useful: they are synthesised, not recorded and not sampled, so no third-party audio licence can attach to them.

The exact artifact that would settle it: the script Serge used to render these three files (the counterpart of tools/build_ringtones.py), committed to tools/. Failing that, a written statement from Serge that he synthesised them. The test that flips the verdict from UNKNOWN to GENERATED is the same one already run on the other 13: run the script into a sandbox tree and show SHA-256 equality with afbcee39e9eac746ea18c7fb1ce6cd8ee721cee8906a8d955bbc749577717c5e (step.wav), 7d009de0eb7c2a95bb37f1343d967f67fb501564452d3be70399e51652eb5d86 (click-up.wav) and 0e2191bb9911d5b3edaa58cb9d64c9e9689977dc5d34d58cff27c91fb1f8ddde (click-down.wav).

3.3 The 16th audio asset — android/app/src/main/res/raw/cadence_alarm.wav

Full evidence: proof/01_findings/S11/13_cadence_alarm.txt.

  • It is a distinct asset, not a copy of any of the 15. Byte comparison against all fifteen files in assets/audio/: byte-identical duplicates found: 0. Waveform correlation on the overlapping samples never exceeds 0.40 (beep.wav, which shares the 2600 Hz carrier); every other file is below 0.38 and most are below 0.01. It is 1.620000 s long — nine alternating 2600 Hz / 3300 Hz notes — where the longest tone in assets/audio/ used for comparison is a different motif entirely.
  • It is GENERATED by the same script, at tools/build_ringtones.py:232-236, and the sandbox run reproduces it byte-for-byte (f32956f65b33858c18e3dcdb1d745165c1ee6b2a8c2a3349d0c63008ffbe5df0). It is not a fourth ungenerated file.
  • History. Added at f47f2e5 (2026-07-23, v0.3.0) together with keep.xml, before the generator was committed; rewritten at 0c17267 (2026-07-24, v0.4.4) by the generator. The pre-generator blob was different (1e78ca8929552f817e211a610c9ade1d75018133edb556b88e752190141f42be), so the current file is the script's output, not the original hand-placed one.
  • It ships in every release. keep.xml pins it: tools:keep="@raw/cadence_alarm", with the comment recording that "v0.3 first build shipped without it". In the release APK it appears as res/pC.wav, 142,928 bytes, SHA-256 identical to the repo file — the resource shrinker renames it but keeps it.
  • Licence exposure: identical to the other twelve — none, because it is original synthesis. It is covered in S11_LICENSES_draft.md §3 as one of the 16 audio assets.

4. What Flutter and the stores require, and what this app surfaces

Flutter's mechanism does not cover bundled fonts. Per the official API documentation (https://api.flutter.dev/flutter/foundation/LicenseRegistry-class.html, retrieved 2026-08-04): "the flutter tool will automatically collect the contents of all the LICENSE files found at the root of each package into a single LICENSE file in the default asset bundle." That is packages. Files under assets/fonts/ are not packages and are never collected.

Measured on the shipped artifact rather than argued (proof/01_findings/S11/09_apk_notices.txt):

NOTICES.Z uncompressed chars: 1381653
  NOTICES contains 'Big Shoulders'      : False
  NOTICES contains 'Chivo'              : False
  NOTICES contains 'DSEG'               : False
  NOTICES contains 'keshikan'           : False
  NOTICES contains 'Open Font License'  : False
  NOTICES contains 'xotypeco'           : False
  NOTICES contains 'Omnibus'            : False

1.38 MB of licence text ships in build/app/outputs/flutter-apk/app-release.apk, and not one word of it concerns the three font families whose binaries sit in the same archive (all seven .ttf files verified present and byte-identical to the repo copies).

Nothing surfaces it to the user either. proof/01_findings/S11/11_no_licence_ui.txt:

grep -rnE 'showLicensePage|showAboutDialog|AboutDialog|LicenseRegistry|LicensePage|addLicense' lib/ test/ android/ ios/ web/
grep exit=1  (1 = no match anywhere)

Store policy. Google Play's Intellectual Property policy (proof/03_market/captures/S11_play_ip_policy.txt, retrieved 2026-08-04): "We don't allow apps or developer accounts that infringe on the intellectual property rights of others (including trademark, copyright, patent, trade secret, and other proprietary rights)", with the Do column reading "Obtain written documentation or a license for any third-party intellectual property you use." Apple's App Review Guidelines §5.2 (proof/03_market/captures/S11_apple_review_guidelines.txt, retrieved 2026-08-04): "Make sure your app only includes content that you created or that you have a license to use. Your app may be removed if you've stepped over the line and used content without permission."

What the app must surface, given the findings: the copyright notice and licence text for Big Shoulders Display, Chivo Mono and DSEG7 Classic, reachable from inside the app. Flutter's showLicensePage is the correct vehicle — register the three notices with LicenseRegistry.addLicense at startup and add one "Licences" row to the existing Settings dialog, which already exists (lib/i18n.dart key settingsTitle). That keeps the notice with the binary in the form OFL clause 2 asks for ("stand-alone text files, human-readable headers or … machine-readable metadata fields … as long as those fields can be easily viewed by the user") and satisfies both stores' "have a licence for what you ship" rule.


5. Name and branding

Trademark clearance for the word "Cadence" belongs to stream 0.10 and is not duplicated here. The narrow IP question — is the artwork original, and does the repo contain anything suggesting otherwise — resolves as follows.

The wordmark. README.md:38 states: "Produit indépendant de MLF/Sezam&Co — publication au nom de Serge. Nom « Cadence » = placeholder à figer avec the project owner." The repo therefore treats the name as provisional and asserts the product is independent of the author's employer — which is the only ownership claim the repo makes about it. No conflicting claim appears anywhere in the tree.

The launcher icons. pubspec.yaml:33-34 states they are generated from Serge's own Photoshop artwork, with a master path. That is an author-original claim, and nothing in the repo contradicts it. The PNGs carry no metadata chunks at all — no XMP, no EXIF, no author or software record (ic_foreground.png, ic_legacy.png, ic_monochrome.png each contain only IHDR plus image data). There is no embedded creator string naming anyone other than Serge, and equally none naming him.

The logo. assets/logo/mark_white.png carries the same claim by inheritance and by inspection: it is the same seven-segment motif as ic_legacy.png, cropped to the upper-right segments and stripped of metadata in the same way. The two share an exact palette — the same orange #E8600F and the same grey #828A80, to the byte (proof/01_findings/S11/14_icon_logo_palette.txt), against a #F4EFE4 field in the icon that matches adaptive_icon_background: "#F4EFE4" at pubspec.yaml:36. The pubspec comment covers the icon explicitly and the logo implicitly; the two are one artwork. Verdict: AUTHOR-ORIGINAL, credibly.

One thing worth stating because it looks like a problem and is not. The icon is a seven-segment digit, and the app also bundles DSEG7 Classic, a seven-segment typeface. It is a fair question whether the icon was traced from the font. Measured (proof/01_findings/S11/12_icon_vs_dseg7.txt):

icon  ink bbox 393x635  aspect 0.6189  ink px 149853
glyph ink bbox 556x901  aspect 0.6171  ink px 248106
after bbox-normalising: glyph ink px 123517  (icon/glyph ink ratio 1.213)
intersection-over-union = 0.7858

Same proportion to within 0.3%, but the icon's segments carry 21% more ink and the shapes overlap only 79%. That is an independent drawing in the same idiom, not a trace. And it would not matter if it were: the OFL states twice that "the requirement for fonts to remain under this license does not apply to any document created using the Font Software" — artwork typeset from an OFL font carries no obligation.

Contradicting evidence found in the repo: none.


6. Findings

  • Severity: BLOCKER
  • Location: pubspec.yaml:48-69 (the seven fonts are declared here); repository root (no licence file exists); build/app/outputs/flutter-apk/app-release.apk (the built artifact)
  • What is wrong: Big Shoulders Display, Chivo Mono and DSEG7 Classic are all licensed under SIL OFL 1.1, which permits bundling and selling with software provided that each copy contains the copyright notice and the licence. The repository tracks zero licence files (git ls-files | grep -icE 'licen|notice|ofl|copying' returns 0), the built APK's generated notice mentions none of the three families, and the app has no licence screen. The OFL's TERMINATION clause makes the licence "null and void if any of the above conditions are not met", so as built the app distributes three copyrighted typefaces with no licence at all. Both stores prohibit that.
  • Evidence:

$ 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. - Why it matters for a restaurant kitchen: it does not affect a service, it affects whether the product survives on the shelf. A takedown after launch pulls the app from every kitchen that bought it, mid-service, with no warning and no route to reinstall until a compliant build is approved. Restaurants that have standardised on it lose their timer board that day. - Justification for BLOCKER (R13): BLOCKER is defined as preventing store submission. The bar is met in the strict sense that the app cannot lawfully be submitted in this state — with the licence terminated by its own terms, the binary contains three copyrighted works the publisher has no right to distribute, which Google Play's IP policy and Apple's §5.2 both forbid. Neither store checks OFL compliance mechanically, so this will not bounce at upload; it is a removal risk that materialises after money has changed hands, which is worse. - Proposed fix: 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 the fix: 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.

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

  • Severity: HIGH
  • Location: assets/fonts/BigShouldersDisplay-{Medium,Bold,ExtraBold}.ttf, assets/fonts/ChivoMono-{Regular,Medium,Bold}.ttf (declared at pubspec.yaml:48-64)
  • What is wrong: the six Google Fonts binaries have had name records 7 (trademark), 8 (manufacturer), 9 (designer), 11 and 12 (vendor and designer URLs), 13 (the licence text) and 25 removed relative to the official releases, and 25 unmapped glyphs dropped. Record 13 is the machine-readable field OFL clause 2 names as one of the three acceptable ways to carry the licence with a copy. Removing it is what turns "the licence travels inside the binary" into "the licence travels nowhere". It also makes each file a Modified Version under the OFL's own definition ("any derivative made by adding to, deleting, or substituting … any of the components of the Original Version"), which is permitted here only because neither family declares a Reserved Font Name — had they, keeping the family name would have been a clause 3 breach. Nothing in the repo records which tool produced these files or where they were fetched from, so the modification cannot be reproduced or audited.
  • Evidence: 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). - Why it matters for a restaurant kitchen: same downstream consequence as S11-F1 — the product's right to exist on the store depends on it. It is called out separately because the fix is different: F1 adds a file, F2 fixes the binaries. - Proposed fix: 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 the fix: 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-F3 — Three shipped sounds have no generator in the repository

  • Severity: MEDIUM
  • Location: assets/audio/step.wav, assets/audio/click-up.wav, assets/audio/click-down.wav; played from lib/audio/audio.dart:89 and lib/audio/audio.dart:92
  • What is wrong: tools/build_ringtones.py is described in its own header as "source of truth for the alarm sounds" (tools/build_ringtones.py:2), and it produces 13 of the 16 audio assets byte-for-byte. It does not produce these three. They were committed at 22902e0 and never touched again, while every other v0.2.0 tone was regenerated at 0c17267. Their origin is therefore not establishable from the repository, which is exactly the position a rights holder's letter puts you in: you cannot show where a shipped asset came from. Signal analysis proves they are synthesised rather than recorded or sampled (§3.2), which is what makes this MEDIUM rather than HIGH — the realistic exposure is provenance record-keeping, not an actual third-party claim.
  • Evidence: 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. - Why it matters for a restaurant kitchen: click-up and click-down are the confirmation sounds on every tile tap and step.wav marks a chain step advancing. If they ever had to be pulled for a provenance question, the board would go silent on interaction — the cook would stop getting the acknowledgement that a timer actually started. - Proposed fix: 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. - How to prove the fix: 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.

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

  • Severity: MEDIUM
  • Location: 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; referenced from web/manifest.json and web/index.html
  • What is wrong: all five are the unmodified flutter create template assets and all five depict the Flutter logo, which is Google's mark. Three are byte-identical to the Flutter SDK 3.44.8 template files; the two maskable icons are the same artwork (verified by inspection — the upstream template files are zero-byte placeholders, so a byte comparison is not available). The Flutter SDK is BSD-3-Clause, which requires that binary redistributions "reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation or other materials provided with the distribution" — no such notice exists in the repo. Separately, shipping another company's logo as your product's icon is what Google Play's Impersonation policy describes as using "app icons … that could mislead users about your app's relationship to someone else".
  • Evidence: 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. - Why it matters for a restaurant kitchen: nothing today — the web target is not what is sold, and the store submissions are Android and iOS. It matters the moment anyone builds and hosts the web version, e.g. as a demo for a restaurant group, at which point the product is presenting Google's logo as its own in the browser tab and in any PWA install. - Proposed fix: 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 the fix: 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

  • Severity: MEDIUM
  • Location: lib/ (whole tree — no call site exists); Settings dialog in lib/ui/modals.dart, strings at lib/i18n.dart (settingsTitle, journalLabel, …)
  • What is wrong: Flutter provides showLicensePage and LicenseRegistry precisely so that an app can display third-party notices, and the build already assembles 1.38 MB of package licence text into the bundle. Cadence never reads it. A cook, a restaurant's IT contact, or a store reviewer has no way to see what the app is built from. This is the delivery half of S11-F1: even once LICENSES.md exists in the repository, a repository file is not "distributed with the product" to the person who installs the APK.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: indirectly — it is the mechanism by which the product stays compliant and therefore stays installed. A kitchen does not read licence pages; it just needs the app not to disappear.
  • Proposed fix: 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 the fix: 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.

S11-F6 — Big Shoulders Display has been retired from Google Fonts, so the bundled version cannot be re-fetched from the family page

  • Severity: LOW
  • Location: pubspec.yaml:48-56; assets/fonts/BigShouldersDisplay-*.ttf
  • What is wrong: the family is no longer served by the Google Fonts download endpoint. It was superseded in February 2025 by a family simply called "Big Shoulders", which is a different font (an optical-size axis was added) and would change the app's typography if swapped in. The licence is unaffected — the retired family is still in the google/fonts repository under ofl/bigshouldersdisplay, which is where §2.1's licence determination was made. This is a reproducibility problem: the pinned v2.002 binaries can only be rebuilt from the repository master, and nothing in the repo records that.
  • Evidence: 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) - Why it matters for a restaurant kitchen: nothing during service. It matters when someone needs to rebuild the fonts — the obvious route (download the family from Google Fonts) silently yields a different typeface, and every countdown digit on the board changes shape. - Proposed fix: 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 the fix: grep -c 'ofl/bigshouldersdisplay' LICENSES.md returns 0 today and non-zero after.


7. Coverage manifest

Every file in the stream's scope, plus the two files added by the coordinator's correction. "Size" is bytes for binaries, lines for text.

File Size What was checked
assets/fonts/BigShouldersDisplay-Medium.ttf 68,592 B SHA-256; file type; full name table dump; OS/2.fsType, achVendID, head.fontRevision; identity verified against the google/fonts variable master instanced at wght 500 (718/718 advance widths identical, max bbox delta 1.0/2000 em); missing name records 8, 9, 11, 12, 13, 25 enumerated; git add-commit; presence and hash in the release APK
assets/fonts/BigShouldersDisplay-Bold.ttf 68,456 B as above at wght 700
assets/fonts/BigShouldersDisplay-ExtraBold.ttf 68,628 B as above at wght 800
assets/fonts/ChivoMono-Regular.ttf 59,412 B SHA-256; file type; full name table dump; OS/2 fields; every mapped glyph outline and advance width compared against the official Google Fonts static (642/642 identical); glyph-count delta (825→800); missing name records 7, 8, 9, 11, 12, 13, 25 enumerated; git add-commit; APK presence and hash
assets/fonts/ChivoMono-Medium.ttf 59,404 B as above
assets/fonts/ChivoMono-Bold.ttf 59,356 B as above
assets/fonts/DSEG7Classic-Bold.ttf 23,040 B SHA-256; file type; full name table dump including the complete OFL text in record 13; OS/2.fsType = 8; byte-identical comparison against the official v0.46 release archive; Reserved Font Name checked; copyright-year discrepancy across three of the author's own sources documented; git add-commit; APK presence and hash
assets/audio/beep.wav 30,914 B SHA-256; RIFF header; regenerated byte-for-byte from build_ringtones.py:94; full git history
assets/audio/bell.wav 97,064 B as above, generator line 106
assets/audio/bowl.wav 308,744 B as above, generator lines 228-229
assets/audio/buzz.wav 61,784 B as above, generator line 126
assets/audio/cascade.wav 366,956 B as above, generator lines 223-224
assets/audio/chime.wav 82,952 B as above, generator lines 110-111
assets/audio/chirp.wav 75,182 B as above, generator lines 197-198
assets/audio/coin.wav 206,870 B as above, generator lines 202-203
assets/audio/fanfare.wav 192,316 B as above, generator lines 209-210
assets/audio/marimba.wav 87,356 B as above, generator line 118
assets/audio/ping.wav 52,964 B as above, generator lines 97-98
assets/audio/pop.wav 124,400 B as above, generator line 215
assets/audio/step.wav 54,728 B SHA-256; RIFF header; blob equality across the whole history; confirmed absent from the generator's output set; signal analysis — duration, zero-sample tail, noise floor, spectral flatness, partial series, envelope decay fit
assets/audio/click-up.wav 28,268 B as above, plus sample-level comparison against click-down.wav (identical frames, peak, RMS, envelope) and odd-harmonic 1/n partial series identified
assets/audio/click-down.wav 28,268 B as above
android/app/src/main/res/raw/cadence_alarm.wav 142,928 B SHA-256; RIFF header; regenerated byte-for-byte from build_ringtones.py:232-236; byte comparison and waveform correlation against all 15 files in assets/audio/ (0 duplicates, max Pearson 0.40); full git history including the pre-generator blob; presence in the release APK as res/pC.wav with matching hash
android/app/src/main/res/raw/keep.xml 6 lines Read in full; records why the alarm is pinned (tools:keep="@raw/cadence_alarm") and that the v0.3 build shipped without it; git add-commit
assets/icon/ic_foreground.png 14,073 B SHA-256; dimensions and colour type; every PNG chunk enumerated for author/EXIF/XMP metadata (none present); git add-commit; pubspec origin claim cross-read
assets/icon/ic_legacy.png 21,103 B as above, plus rendered and compared geometrically against the DSEG7 Classic Bold 0 glyph (aspect, ink count, intersection-over-union)
assets/icon/ic_monochrome.png 10,517 B as above (metadata, hash, git)
assets/logo/mark_white.png 8,089 B SHA-256; dimensions; PNG chunk scan (no metadata); rendered and compared against ic_legacy.png, including an exact dominant-palette match (#E8600F, #828A80); git add-commit; lib/ui/logo.dart:13 reference confirmed via the code map
web/favicon.png 917 B SHA-256; PNG chunks (Adobe XMP present); byte-identical comparison against the Flutter SDK 3.44.8 template
web/icons/Icon-192.png 5,292 B SHA-256; PNG chunks incl. tIME 2020-01-07; byte-identical to the Flutter SDK template
web/icons/Icon-512.png 8,252 B as above
web/icons/Icon-maskable-192.png 5,594 B SHA-256; PNG chunks; rendered and confirmed to be the Flutter logo; upstream template file is a zero-byte placeholder so a byte comparison is unavailable
web/icons/Icon-maskable-512.png 20,998 B as above
web/index.html 1,519 B Read in full for asset references and any embedded third-party script or font link — none
web/manifest.json 910 B Read in full for icon references and branding strings
tools/build_ringtones.py 238 lines Read in full; every import, every file-open mode, every waveform construction traced; executed in an isolated sandbox tree and all 13 outputs hash-compared against the repo
pubspec.yaml 68 lines Read in full; font family declarations (48-69), asset directories (44-46), launcher-icon config and origin comment (33-39)
README.md 40 lines Read for ownership and naming claims; line 38 quoted
build/app/outputs/flutter-apk/app-release.apk 53,629,091 B NOTICES.Z extracted and searched for all three font families; FontManifest.json read; all seven .ttf files extracted and hash-compared against the repo; res/pC.wav extracted and hash-compared

Scoped areas where nothing was found: the PNG metadata scan across all nine images turned up no third-party author, no copyright string, and no software record that would contradict the author-original claim — three of them carry no ancillary chunks at all. web/index.html and web/manifest.json reference no external font, script, or CDN. No asset in the repository is a sampled recording, and no third-party audio licence applies anywhere.

S11 refutation — asset licensing and IP provenanceagent_reports/S11_refute.md · raw .md

S11 refutation — asset licensing and IP provenance

Refuter for stream S11. Fresh context, governed by R5. Subject read-only at 03a176e72ef0075eec86b8915cbe6e93042a3b9d, TREE_STATE: CLEAN stamped on every recorded run. Nothing under cadence-app was written, staged, or checked out.

Raw evidence: proof/01_findings/S11_refute/R01…R31 (31 recorded runs via proof/run_and_record.sh [not published]).


0. Verdict table — one row per S11 finding

Finding S11 severity Refuter verdict Reason
S11-F1 — three OFL families shipped with no notice and no licence text; licence terminated BLOCKER REFUTED as stated → survives as HIGH 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 own published copy. The real defect covers six files, not three families. Termination is further contested by the licensor's own FAQ 1.10. §1
S11-F2 — six binaries ship with name ID 13 stripped, no provenance record HIGH CONFIRMED as fact, OVERSTATED in characterisation → MEDIUM The stripping is real and reproduced (§4). But upstream 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. §4
S11-F3 — three shipped sounds have no generator in the repo MEDIUM CONFIRMED; its supporting inference is INVALID The measurements reproduce exactly and I failed to flip the verdict (§5.3). But "proven synthetic → no third-party licence can attach" is a non-sequitur (§5.2). The conclusion is right for a reason S11 did not give.
S11-F4web/ ships the Flutter logo, no BSD-3 notice MEDIUM CONFIRMED and STRENGTHENED All five icons now byte-proven, not three (§6.1). Trademark question answered from the guidelines themselves, which S11 never fetched (§6.2).
S11-F5 — no licence surface in the app MEDIUM CONFIRMED Verified from the Flutter SDK source at the pinned version, not from the API doc (§3.3).
S11-F6 — Big Shoulders Display retired from the Google Fonts endpoint LOW CONFIRMED ofl/bigshouldersdisplay still carries the family and the licence; the variable master re-instances to the bundled binaries (§2.1).
NEW — R-F1 BLOCKER The iOS App Store icon is Google's Flutter logo. All 15 AppIcon.appiconset PNGs and all 3 LaunchImage PNGs are byte-identical to the Flutter template. S11 omitted ios/ entirely. §7.1
NEW — R-F2 … R-F7 see §7 Coverage gap of 33 tracked binaries; wrong Flutter copyright year in the draft; citation defects; measurement-method ambiguity.

LICENSES draft verdict: NOT APPROVED — 7 corrections required before Phase 4 commits it (§8).


The finding is a four-link chain. Each link was re-derived independently.

SURVIVES. Re-derived from each copyright holder's own distribution, never from an aggregator.

Big Shoulders Displayhttps://raw.githubusercontent.com/google/fonts/main/ofl/bigshouldersdisplay/METADATA.pb, retrieved 2026-08-04 (R07):

name: "Big Shoulders Display"
designer: "Patric King"
license: "OFL"
date_added: "2019-09-11"
  copyright: "Copyright 2019 The Big Shoulders Project Authors (https://github.com/xotypeco/big_shoulders)"
source { repository_url: "https://github.com/xotypeco/big_shoulders" }

The directory holds exactly one font file, the variable master BigShouldersDisplay[wght].ttf (219,532 B). I instanced it at each bundled weight with fontTools.varLib.instancer and compared against the bundled statics (R09):

wght=500 -> BigShouldersDisplay-Medium.ttf:    common=718 only_up=0 only_bundled=0 width_identical=718/718
wght=700 -> BigShouldersDisplay-Bold.ttf:      common=718 only_up=0 only_bundled=0 width_identical=718/718
wght=800 -> BigShouldersDisplay-ExtraBold.ttf: common=718 only_up=0 only_bundled=0 width_identical=718/718

Identical character coverage and identical advance widths on all 718 mapped characters. head.fontRevision 2.002, name ID 5 Version 2.002. Identity established.

Chivo Monoofl/chivomono/METADATA.pb, license: "OFL", designer Omnibus-Type, source github.com/Omnibus-Type/Chivo. Instanced from ChivoMono[wght].ttf (R09):

wght=500 -> ChivoMono-Medium.ttf: common=642 outline_identical=642/642 width_identical=642/642

Every mapped glyph outline in the bundled Medium is point-for-point the upstream variable master instanced at weight 500. That is stronger than S11's bounding-box tolerance. v1.008.

DSEG7 Classic — checked separately, as instructed, because it is an independent author's font and not a Google Fonts release. Source: keshikan's own GitHub release https://github.com/keshikan/DSEG/releases/download/v0.46/fonts-DSEG_v046.zip (sha256 a6c2f43520971ca8067262e78d49025e605f749bf716ec5394bad9a0ee1c238c, retrieved 2026-08-04). R08:

d16181c4eeae29e695ca547ee9be437d01d4909ac1f8f349f04176dc6858873b  fonts-DSEG_v046/DSEG7-Classic/DSEG7Classic-Bold.ttf
d16181c4eeae29e695ca547ee9be437d01d4909ac1f8f349f04176dc6858873b  .../assets/fonts/DSEG7Classic-Bold.ttf
RESULT: BIT-IDENTICAL

DSEG-LICENSE.txt in that archive is SIL OFL 1.1, Copyright (c) 2017, keshikan (http://www.keshikan.net), with Reserved Font Name "DSEG".

All three families: OFL 1.1 confirmed from the holder's own distribution.

This is the hinge, and S11's reading is CORRECT — but for a factual reason S11 never stated.

The operative clause, verbatim from SIL's own canonical copy (https://openfontlicense.org/documents/OFL.txt, HTTP 200, retrieved 2026-08-04, R04):

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

Clause 2 governs "bundled … with any software" on its face, so the condition is not confined to redistributing loose font files. But the sharper answer is that Cadence does not embed its fonts at all — it bundles them. Measured on the shipped artifact (R10):

assets/flutter_assets/assets/fonts/BigShouldersDisplay-Bold.ttf   68456 B  677d6cfb…  (== repo)
assets/flutter_assets/assets/fonts/BigShouldersDisplay-ExtraBold.ttf 68628 B bf0476be… (== repo)
assets/flutter_assets/assets/fonts/BigShouldersDisplay-Medium.ttf 68592 B  a9b588db…  (== repo)
assets/flutter_assets/assets/fonts/ChivoMono-Bold.ttf             59356 B  97ab174a…  (== repo)
assets/flutter_assets/assets/fonts/ChivoMono-Medium.ttf           59404 B  efc12610…  (== repo)
assets/flutter_assets/assets/fonts/ChivoMono-Regular.ttf          59412 B  3bfc5e83…  (== repo)
assets/flutter_assets/assets/fonts/DSEG7Classic-Bold.ttf          23040 B  d16181c4…  (== repo)

All seven .ttf files sit in the APK as whole, unmodified, byte-identical font files inside a ZIP. SIL's own FAQ (https://openfontlicense.org/documents/OFL-FAQ.txt, retrieved 2026-08-04, R06) draws the distinction explicitly:

1.11 By 'embedding' we mean inclusion of the font in a document or file in a way that makes extraction (and redistribution) difficult or clearly discouraged. … the font data format might be altered, and only a subset of the font … might be included. Any other means of delivering a font to another person is considered 'distribution', and needs to be accompanied by any copyright notices and licensing information available in OFL.txt.

1.15 Certain document formats may allow the inclusion of an unmodified font within their file structure which may consist of a compressed folder … Including fonts within such a structure is understood as being different from embedding but rather similar to bundling … In this case the font is conveyed unchanged whereas embedding a font usually transforms it from the original format. … Even if the font travels inside the document as one of its assets, it should not lose its authorship information and licensing.

An APK is a compressed folder structure conveying the fonts unchanged. This is bundling, and the on-point mobile-app entry states the obligation directly:

1.20 If you bundle a font under the OFL with your mobile app you must comply with the terms of the license. At a minimum you must include the copyright statement, the license notice and the license text.

Link 2 survives. The obligation attaches. S11 is not overstating here.

REFUTED. This is where S11-F1 breaks.

I dumped every name record from all seven binaries myself (R05). The licence-bearing records:

File ID 0 (copyright) ID 13 (licence) ID 14 (licence URL)
BigShouldersDisplay-{Medium,Bold,ExtraBold}.ttf presentCopyright 2019 The Big Shoulders Project Authors (https://github.com/xotypeco/big_shoulders) absent https://scripts.sil.org/OFL
ChivoMono-{Regular,Medium,Bold}.ttf presentCopyright 2018 The Chivo Project Authors (https://github.com/Omnibus-Type/Chivo) absent https://scripts.sil.org/OFL
DSEG7Classic-Bold.ttf Created by Keshikan\nwith FontForge 2.0 present — the entire OFL 1.1 text, 4,390 chars, headed Copyright (c) 2018, keshikan (http://www.keshikan.net), with Reserved Font Name "DSEG". http://scripts.sil.org/OFL

Two consequences S11 missed:

  1. The copyright notice ships for all seven files, inside name ID 0 (or ID 13 for DSEG) — which is exactly the carrier clause 2 names, "the appropriate machine-readable metadata fields within text or binary files". S11-F1's "the app ships neither" is false on this half for every file.
  2. DSEG7 Classic ships the complete licence text inside the shipped binary. I diffed it against every published copy (R25):
4128 chars  sha256 224f3d65fac0ee2c  google/fonts ofl/bigshouldersdisplay/OFL.txt
4128 chars  sha256 224f3d65fac0ee2c  google/fonts ofl/chivomono/OFL.txt
4128 chars  sha256 224f3d65fac0ee2c  keshikan DSEG v0.46 DSEG-LICENSE.txt
4128 chars  sha256 224f3d65fac0ee2c  DSEG7Classic-Bold.ttf name ID 13 (shipped binary)

The licence text inside the shipped binary is byte-identical to the licence published with all three families. For DSEG7 Classic, clause 2 is satisfied on the face of the artifact.

So the defect is not "three typefaces distributed unlicensed". It is: for six of seven files the licence text itself does not travel with the copy — only a URL pointing to it does.

And SIL says that is enough:

1.10 Does the full OFL license text always need to accompany the font? The only situation in which an OFL font can be distributed without the text of the OFL (either in a separate file or in font metadata), is when a font is embedded in a document or bundled within a program. In the case of metadata included within a font, it is legally sufficient to include only a link to the text of the OFL on https://openfontlicense.org, but we strongly recommend against this.

FAQ 1.10 and FAQ 1.20 are in direct conflict on this exact question, in the same document, published by the same licensor. Under 1.10 the six files comply. Under 1.20 they do not. S11 disclosed neither.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

The clause is real and quoted correctly. But it operates per-copy on unmet conditions, and:

  • DSEG7 Classic meets the condition on any reading → its licence does not terminate. S11's "three typefaces distributed unlicensed" is disproven for one third of the claim by the binary itself.
  • For the other six, termination follows only on the FAQ 1.20 reading, and is expressly excused on the FAQ 1.10 reading.

Verdict on S11-F1

Does not survive as a BLOCKER. Survives as HIGH, restated as: six of the seven bundled font files carry the copyright notice and a licence URL but not the licence text, and nothing in the app surfaces either.

Three independent reasons the BLOCKER severity fails R13:

  1. The factual predicate is wrong. The notice ships for all seven; the licence text ships for one.
  2. The legal predicate is contested by the licensor's own published guidance, undisclosed by S11. R3 demands a binary verdict: the breach is disproven for DSEG7 Classic and not established for the six.
  3. R13 defines BLOCKER as "prevents store submission". S11's own justification concedes "Neither store checks OFL compliance mechanically, so this will not bounce at upload." That is a concession that the bar is not met.

The remedy is unchanged — ship LICENSES.md and surface it — because it costs nothing and makes the 1.10-vs-1.20 conflict moot. Only the severity and the wording change.

Legal position. Under OFL clause 2 read strictly, together with FAQ 1.20, six bundled font files are distributed with the copyright notice but without the licence text, carrying only a URL to it. Under FAQ 1.10 that is expressly sufficient and there is no breach at all. DSEG7 Classic complies on both readings. The obligation attaches because the APK bundles rather than embeds (FAQ 1.11, 1.15). The reciprocal obligation touches only the seven .ttf files, never Cadence's own code or artwork (clause 5, and FAQ 1.13 for artwork typeset in an OFL font).

Practical risk. Distinct and much lower. Neither store inspects font name tables; S11 established that and I confirm it. The realistic route to harm is a rights-holder complaint, and both affected holders publish through Google Fonts, whose own web-font service delivers stripped binaries carrying no licence text either — so the complaint would have to be made by a party whose primary distribution channel does the same thing. The material exposure is not takedown. It is that the product cannot answer "show me your licence for this" during acquirer or enterprise-customer due diligence, which is a commercial-friction cost, not a store-removal cost. Fix it because it is a half-day of work, not because the app is in danger.


2. Provenance table — spot-checks

16 of 32 rows re-derived (brief required ≥8), covering every verdict class. R19, R01.

Row Class S11 claim Re-derived Result
BigShouldersDisplay-Medium.ttf THIRD-PARTY added 22902e0 2026-07-23, never rewritten blob a20c2bdc identical at 22902e0/f47f2e5/0c17267/8461639/HEAD
ChivoMono-Regular.ttf THIRD-PARTY same blob eb05d73b identical throughout
DSEG7Classic-Bold.ttf THIRD-PARTY same; byte-identical to official v0.46 blob 5f71db4e throughout; R08 BIT-IDENTICAL
beep.wav GENERATED added 22902e0, rewritten 0c17267 561f5700d43d1571 at 0c17267
chirp.wav GENERATED added 8461639 2026-07-28 (v0.4.10) absent at 0c17267, present d1951376 at 8461639
cadence_alarm.wav GENERATED added f47f2e5 v0.3.0, rewritten 0c17267 absent at 22902e0; 0833a4f6 at f47f2e5507f34e8 at 0c17267
step.wav UNKNOWN added 22902e0, never rewritten blob c6afec8e identical throughout
click-up.wav UNKNOWN same blob 2a5aeab7 identical throughout
click-down.wav UNKNOWN same blob b02ac4ed identical throughout
ic_foreground.png AUTHOR-ORIGINAL added 22902e0; PNG carries no metadata a6e56fbe throughout; chunks = IHDR, IDAT, IEND only (R20)
ic_legacy.png AUTHOR-ORIGINAL same dedbe3ed; chunks IHDR, IDAT, IEND; palette #F4EFE4 85.43%, #E8600F, #828A80 (R26)
ic_monochrome.png AUTHOR-ORIGINAL same aa80d394; no ancillary chunks
mark_white.png AUTHOR-ORIGINAL same artwork family, exact palette match 56d6807f; shares #E8600F and #828A80 to the byte with ic_legacy.png (R26)
web/favicon.png THIRD-PARTY byte-identical to Flutter 3.44.8 template R11: BIT-IDENTICAL, 7ab2525f…
web/icons/Icon-maskable-512.png THIRD-PARTY "byte comparison unavailable" byte comparison IS available — see §6.1 ✗ superseded
tools/build_ringtones.py AUTHOR-ORIGINAL 238 lines, added 0c17267, updated 8461639 238 lines; blob 1c30ea213929e387

Rows I could not reproduce: zero. Every provenance and history claim re-derived exactly. One row (Icon-maskable-512.png) was reproduced and improved — S11's stated limitation is not real.

Every generator line reference in the table was opened and confirmed (R22): :94 beep, :97-98 ping, :106 bell, :110-111 chime, :118 marimba, :126 buzz, :197-198 chirp, :202-203 coin, :209-210 fanfare, :215 pop, :223-224 cascade, :228-229 bowl, :232-236 cadence_alarm. All correct.


3. The three artifact-level claims

3.1 Ringtone byte-for-byte regeneration — HELD, exactly

I copied tools/build_ringtones.py alone into an empty sandbox (a scratch working copy), ran it under the workspace interpreter (Python 3.11.15, numpy 2.4.2), and hashed every output against the committed file (R02, R03):

--- 13/13 byte-identical ---
in repo but NOT produced by generator: ['click-down.wav', 'click-up.wav', 'step.wav']

All thirteen SHA-256 values match S11's to the character. The strongest claim in the stream reproduces independently, on a different run, from a clean tree.

3.2 The release APK ships no font notice — CONFIRMED

R10, run against build/app/outputs/flutter-apk/app-release.apk (53,629,091 B, sha256 f11a484d821ed4ab11121ea01f7291841dad320def22cbf9b8f9f61c77e7ca9e, 412 entries):

NOTICES.Z compressed bytes: 114355
NOTICES uncompressed chars: 1381705
  contains 'Big Shoulders'    : False (count 0)
  contains 'Chivo'            : False (count 0)
  contains 'DSEG'             : False (count 0)
  contains 'keshikan'         : False (count 0)
  contains 'Open Font License': False (count 0)
  contains 'OFL'              : False (count 0)
  contains 'scripts.sil.org'  : False (count 0)
  contains 'openfontlicense'  : False (count 0)

I extended the search beyond S11's seven needles to OFL, scripts.sil.org and openfontlicense: still zero. The only licence-named file anywhere in the archive besides NOTICES.Z is META-INF/androidx/annotation/annotation/LICENSE.txt.

FontManifest.json confirms all three families are declared and loaded.

3.3 Would LicenseRegistry / showLicensePage surface anything? — No. CONFIRMED from source

S11 argued this from the API doc. I verified it against the Flutter SDK at the pinned version (R24, packages/flutter/lib/src/services/binding.dart at tag 3.44.8):

196:      onListen: () async {
202:          rawLicenses = await rootBundle.loadString('NOTICES', cache: false);
206:          final ByteData licenseBytes = await rootBundle.load('NOTICES.Z');
218:        final List<LicenseEntry> licenses = await compute<String, List<LicenseEntry>>(
219:          _parseLicenses,

The default collector reads NOTICES.Z from the root bundle and parses it. That is its only source. So showLicensePage would render the 1.38 MB of package notices — and nothing about the three font families, because §3.2 shows they are not in it. No font under assets/fonts/ is or can be auto-registered. Nothing calls it in any case (R22):

grep -rnE 'showLicensePage|showAboutDialog|AboutDialog|LicenseRegistry|LicensePage|addLicense' lib/ test/ android/ ios/ web/
grep exit=1

and git ls-files | grep -icE 'licen|notice|ofl|copying' returns 0. Both confirmed.


4. S11-F2 — the stripped name records

The fact is confirmed and reproduced. Instancing each upstream variable master and diffing the name tables (R09):

Big Shoulders Display — upstream master nameIDs: [0,1,2,3,4,5,6,8,9,11,12,13,14,16,17,25]
  wght=500 -> Medium.ttf     nameIDs stripped vs master: [8, 9, 11, 12, 13, 25]
  wght=700 -> Bold.ttf       nameIDs stripped vs master: [8, 9, 11, 12, 13, 16, 17, 25]
  wght=800 -> ExtraBold.ttf  nameIDs stripped vs master: [8, 9, 11, 12, 13, 25]

Chivo Mono — upstream master nameIDs: [0,1,2,3,4,5,6,7,8,9,11,12,13,14,16,17,25]
  wght=400 -> Regular.ttf    nameIDs stripped vs master: [7, 8, 9, 11, 12, 13, 16, 17, 25]
  wght=500 -> Medium.ttf     nameIDs stripped vs master: [7, 8, 9, 11, 12, 13, 25]
  wght=700 -> Bold.ttf       nameIDs stripped vs master: [7, 8, 9, 11, 12, 13, 16, 17, 25]

But S11's characterisation is overstated in two ways. Upstream name ID 13 reads, in full:

'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'

That is a one-sentence notice plus a URL, not the OFL text. So:

  1. S11-F2's "Removing it is what turns 'the licence travels inside the binary' into 'the licence travels nowhere'" is wrong. Restoring ID 13 would not put the licence text inside the binary. It would restore one sentence.
  2. name ID 14 — carrying the identical URL https://scripts.sil.org/OFLis already present in all six bundled files. The licence-carrying delta between the bundled copies and pristine upstream is that single sentence, nothing more.

S11-F2's proposed fix ("Expect roughly +26 KB … that is the price of shipping the licence inside the binary") therefore describes a benefit it does not deliver. The fix is still worth doing — restoring designer, manufacturer and vendor attribution has independent value — but for provenance, not for licence delivery. Severity MEDIUM, not HIGH.

S11's Modified-Version analysis is correct: instancing plus record removal makes each file a Modified Version under the OFL definition, which is permitted because neither family declares a Reserved Font Name (verified: bsd_OFL.txt and cm_OFL.txt line 1 carry a bare copyright line with no with Reserved Font Name clause, R07). DSEG's RFN "DSEG" is used lawfully because the file is bit-identical to the Original Version.


5. The three UNKNOWN WAVs — attacking the inference

5.1 The measurements are correct and reproduce

Independently re-measured (R20, R21). Every figure S11 published reproduces:

step.wav        frames=27342  duration=0.620000 s   exact-zero=11279 (41.3%)  leading=4  trailing=11026
click-up.wav    frames=14112  duration=0.320000 s   exact-zero=11025 (78.1%)  trailing=11025
click-down.wav  frames=14112  duration=0.320000 s   exact-zero=11025 (78.1%)  trailing=11025

click-up vs click-down: peak_a=peak_b=7864  rms_a=rms_b=932.1281
  5ms peak-envelope arrays identical: True  max abs diff=0.0

S11's harmonic claim was the one I most expected to fail, because a first pass over the whole 0.32 s file gives 3x = 0.2025, nowhere near 1/3. Measured properly over the 70 ms active segment with 0.042 Hz bin resolution (R21), it holds to four decimals:

assets/audio/click-up.wav: active samples=3087 (70.00 ms), tail zeros=11025
  f0 = 1249.98 Hz
   k   k*f0(Hz)   measured_rel   1/k      1/k^2
   1     1250.0      1.0000   1.0000   1.0000
   2     2500.0      0.0051   0.5000   0.2500
   3     3749.9      0.3338   0.3333   0.1111
   4     4999.9      0.0048   0.2500   0.0625
   5     6249.9      0.2003   0.2000   0.0400
   7     8749.8      0.1430   0.1429   0.0204
   9    11249.8      0.1112   0.1111   0.0123
  11    13749.7      0.0909   0.0909   0.0083

Odd harmonics at 1/n, even harmonics at 0.005. Textbook square wave. S11's claim 4 is right. Two corrections of detail: click-down's fundamental is 820.07 Hz, not S11's 818.8 Hz (an artifact of S11's coarser FFT window), and click-up's is 1249.98 Hz.

New, beyond S11: the envelope is a two-sided exponential with the peak at sample 441 = exactly 10.000 ms, exponential attack fit R²=0.99977 at +787.71/s, exponential decay fit R²=0.99978 at −131.29/s — the attack rate is exactly 6× the decay rate, the active segment is exactly 70.00 ms and the silent tail exactly 250.00 ms. Machine synthesis is not in doubt.

5.2 The inference is invalid — this is the real defect

S11 §3.2 concludes: "they are synthesised, not recorded and not sampled, so no third-party audio licence can attach to them." The LICENSES draft repeats it as flat fact: "No sound library, sample pack, or recording is used anywhere in this product, and no third-party audio licence applies to any of the sixteen."

That does not follow. Copyright attaches to a work by authorship, not by production method. Proving a file was synthesised establishes how it was made, not who made it. A synthesised tone can be someone else's copyrighted work — a UI sound lifted from another application is synthesised too. The acoustic evidence eliminates exactly one risk: that the file came from a sampled recording library. It says nothing about whether Serge authored it. Two further gaps: some synthesis tools' output licences carry terms of their own, and short distinctive sounds can be registered as sound marks — neither of which "it is synthetic" addresses.

What actually retires the risk here is originality, not synthesis: a 70 ms square-wave burst at a single fundamental with an exponential attack and decay contains no protectable expression. There is nothing to own. That is the argument S11 should have made, and it is the argument the LICENSES draft must make, because the draft is a legally-operative notice, not an analysis.

5.3 I ran the flip test S11 named, and it did not flip

S11 states the test that would move these three from UNKNOWN to GENERATED. I attempted it. A four-parameter reconstruction — square carrier at f0, 10 ms exponential attack, 60 ms exponential decay, peak 0.24, 250 ms silence — lands within a maximum absolute error of 7 LSB out of 32,768 (−73 dBFS) across all 14,112 samples, but 2,053 samples differ. Byte-equality not achieved.

S11-F3's UNKNOWN verdict stands, and its named settling artifact — Serge's generator script, committed to tools/ — remains the correct one.


6.1 All five web/ icons are byte-proven — S11's stated limitation is not real

S11 proved three and wrote of the two maskable icons: "the upstream template files are zero-byte placeholders so a byte comparison is unavailable." The placeholder observation is correct (R11: the GitHub API reports Icon-maskable-{192,512}.png.img.tmpl, size: 0, sha e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 — the git empty blob), but the conclusion is not. .img.tmpl files are materialised from the pub package flutter_template_images, which flutter_tools 3.44.8 pins at 5.0.0 (R12, packages/flutter_tools/pubspec.yaml:25). Fetching that archive (R13, sha256 0120589a786dbae4e86af1f61748baccd8530abd56a60e7a13479647a75222fe):

BIT-IDENTICAL  web/icons/Icon-maskable-192.png  pkg_sha=d2c842e2… repo_sha=d2c842e2…
BIT-IDENTICAL  web/icons/Icon-maskable-512.png  pkg_sha=6aee06cd… repo_sha=6aee06cd…

All five web/ icons are now byte-identical to unmodified Flutter template assets. I rendered Icon-maskable-512.png and confirmed visually that it is the Flutter logo.

6.2 Does shipping the Flutter logo raise a trademark issue? — Yes, definitively

S11 argued this from Google Play's Impersonation policy and never fetched the Flutter brand guidelines. I fetched both.

https://docs.flutter.dev/brand, "Flutter Brand Guidelines", page last updated 2026-05-05, retrieved 2026-08-04 via utilities/chrome.py (R17, R18):

The "Flutter" name and logo are trademarks owned by Google. … Use of the Flutter trademarks that is not expressly permitted by these guidelines is prohibited absent written permission from Google.

You are free to use the Flutter trademarks: (i) in connection with your download and use of the Flutter SDK to build and develop apps, (ii) in training materials …, and (iii) to show your support for the use of the Flutter SDK by members of the developer community.

DON'T: Don't incorporate the Flutter trademarks into your own product names, service names, trademarks, logos, or company names.

And https://flutter.dev/brand, retrieved 2026-08-04 (R15):

Do not use the Flutter mark or any variant of the Flutter mark in conjunction with the overall name of your application, product, service, or website. … The standard lockup … should never be used in-product or in a way that implies that Flutter is endorsing or has built the product.

Using the Flutter logo as the product's own icon is not within any of the three expressly permitted categories, and is squarely within the "don't incorporate into your own … logos" prohibition and the "never in-product" instruction. It is a trademark-guideline violation.

S11's supporting Play quotation is substantively accurate but was never captured. I captured it (R23, Impersonation, Play Console Help, retrieved 2026-08-04):

We don't allow apps that mislead users by impersonating someone else … Be careful not to use app icons, descriptions, titles, or in-app elements that could mislead users about your app's relationship to someone else or another app.


7. Findings S11 missed (R5 deliverable)

  • Severity: BLOCKER
  • Location: ios/Runner/Assets.xcassets/AppIcon.appiconset/*.png (15 files), ios/Runner/Assets.xcassets/LaunchImage.imageset/*.png (3 files); root cause at pubspec.yaml:35 (valid at 03a176e)
  • What is wrong: flutter_launcher_icons is configured android: true / ios: false. The Android launcher icons were regenerated from Serge's artwork; the iOS icon set never was. Every one of the 18 tracked iOS image assets is byte-identical to the unmodified Flutter template, and the 1024×1024 App Store marketing icon — the one a human reviewer looks at on every submission — is Google's Flutter logo. S11 scoped ios/ out entirely and reported the Flutter-logo problem as affecting only the unpublished web/ target, "nothing today". It affects the primary iOS submission.
  • Evidence: 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. - Why it is a BLOCKER under R13 — this one genuinely prevents submission. Apple App Store Review Guidelines §4.1(c), from the stored capture proof/03_market/captures/S11_apple_review_guidelines.txt: "You cannot use another developer's icon, brand, or product name in your app's icon or name, without approval from the developer." And §5.2.1: "Don't use protected third-party material such as trademarks … in your app without permission." Unlike the OFL question, this is checked on every submission by a human looking at the 1024×1024 icon, and 4.1 Copycats is a routine rejection ground. It also breaches Google's own Flutter brand guidelines (§6.2). - Why it matters for a restaurant kitchen: the product cannot reach the iPad in the kitchen at all. iOS review rejects at submission, before any restaurant ever installs it. - Proposed fix: 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 the fix: 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 may appear in flutter_template_images 5.0.0.

R-F2 — S11's asset inventory omits 33 tracked binaries

  • Severity: MEDIUM (coverage defect in the audit, not in the app)
  • Location: android/app/src/main/res/{mipmap-*,drawable-*}/*.png (15), ios/Runner/Assets.xcassets/**/*.png (18)
  • What is wrong: S11 states "Total binary assets in scope: 32" and its coverage manifest lists 36 rows. The repository tracks 65 binary image/font/audio assets. The 33 omitted are the generated Android launcher icons and the entire iOS asset catalogue — and the iOS omission concealed R-F1.
  • Evidence: R28 full git ls-files inventory; R31 per-file sizes and hashes for all 33.
  • Proposed fix: 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.
  • Severity: MEDIUM
  • Location: findings/S11_LICENSES_draft.md:110
  • What is wrong: the draft reproduces Copyright 2014 The Flutter Authors. All rights reserved. That is the flutter/flutter root LICENSE, which covers the three .copy.tmpl icons. The two maskable icons come from the pub package flutter_template_images 5.0.0, whose LICENSE reads Copyright 2013 The Flutter Authors. All rights reserved. BSD-3-Clause conditions binary redistribution on reproducing "the above copyright notice" — the notice attached to the work. The draft reproduces a notice that does not cover two of the five files it lists.
  • Evidence: 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. - Proposed fix: reproduce both, e.g. Copyright 2013, 2014 The Flutter Authors. All rights reserved., and name the two sources.

R-F4 — Citation defects in S11 (three pubspec.yaml line references)

  • Severity: LOW
  • Evidence: 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 ✓.

R-F5 — The NOTICES character count is method-dependent

  • Severity: LOW
  • What is wrong: S11 and the draft both publish 1,381,653. Reading the same bytes without newline translation gives 1,381,705. 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 should say which measurement it is.
  • Evidence: bytes-decoded chars 1381705 CRLF count 52 newline-translated chars 1381653 (R10 and the follow-up check).

R-F6 — assets/icon/*.png are build inputs, not shipped bundle assets

  • Severity: LOW
  • What is wrong: pubspec.yaml:44-46 lists only assets/audio/ and assets/logo/. The three assets/icon/ PNGs are not in the Flutter asset bundle; they are inputs to flutter_launcher_icons and reach the APK only as Android resources. S11's manifest does not distinguish the two. No licence consequence — they are author-original — but the distinction matters for any claim about what is "distributed".
  • Evidence: 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.
  • Severity: LOW
  • What is wrong: S11 §2.4 asserts "All seven binaries … name a copyright holder". For DSEG7Classic-Bold.ttf, name ID 0 is Created by Keshikan\nwith FontForge 2.0 (http://fontforge.sf.net)\n — a generator string, not a copyright notice. The copyright statement is inside ID 13. The conclusion holds; the supporting statement does not, per-record.
  • Evidence: R05.

8. Verdict on findings/S11_LICENSES_draft.mdNOT APPROVED

Phase 4 will commit this as LICENSES.md. It is close, and one part of it is excellent, but it contains a factual assertion it cannot support and reproduces a copyright notice that does not cover two of the works it lists.

What is correct and should not be touched. The reproduced OFL 1.1 body in §4 is byte-identical to the licence published with all three families and to the copy embedded in the shipped DSEG binary (R25, sha256 224f3d65fac0ee2c…, 4,128 chars, all five sources agreeing). It differs from SIL's current web copy only by one trailing space on line 14 that SIL has since removed. The draft reproduces the licence as distributed with the fonts, which is the correct choice. Every SHA-256 in §1 matches the repository exactly (R01). The version, designer, upstream-project and distribution rows are all confirmed. §2's BSD-3-Clause text is verbatim correct.

Required corrections:

  1. §3, blanket audio claim — factually unsupported. Delete or qualify "No sound library, sample pack, or recording is used anywhere in this product, and no third-party audio licence applies to any of the sixteen." It is provable for the 13 the repository's own generator reproduces byte-for-byte; it is not provable for step.wav, click-up.wav, click-down.wav, whose origin the repository cannot establish (§5.2). Replace with the argument that actually holds: those three are single-oscillator square-wave bursts of 70 ms with an exponential attack and decay, containing no protectable expression, and the acoustic evidence rules out a sampled recording.
  2. §2, wrong copyright year. Copyright 2014 The Flutter Authors does not cover Icon-maskable-192.png / Icon-maskable-512.png, which come from flutter_template_images 5.0.0 under Copyright 2013 The Flutter Authors. Reproduce both years and name both sources (R-F3).
  3. §2 must cover the iOS icon set. The draft lists five web/ files. Eighteen further Flutter template images ship under ios/Runner/Assets.xcassets/ (R-F1). A notice that omits the app's own App Store icon is incomplete in the way that matters most.
  4. §2's maintainer note is wrong about scope. "these are placeholders. The web target is not published." True of web/; false of ios/, which is a primary submission target. Restate: the iOS AppIcon set must be regenerated before submission, pubspec.yaml:35 ios: false is the cause.
  5. §1 line reference. pubspec.yaml:48-6948-68 (R-F4).
  6. §1.1 and §1.2 should state what each binary actually carries. The draft says name records "other than IDs 0-6, 14, 16, 17 removed" — accurate, but it buries the operative point. State plainly that each bundled file carries the copyright notice in name ID 0 and the licence URL in name ID 14, and that the full licence text is supplied by this file. That is what makes the document discharge OFL clause 2, and a reader must be able to see it.
  7. §5 overclaims what the file achieves. "That notice must be reachable from the application's own interface; see the Settings dialog." No such row exists at 03a176e (R22, grep exits 1). Either write it as the pending action it is, or commit the file only alongside the showLicensePage row.

With those seven applied, the draft discharges the obligation on any reading of clause 2 — the copyright notices are reproduced, the full licence text is reproduced verbatim as distributed, and the per-file SHA-256 provenance table answers the question the metadata stripping made unanswerable.


9. Coverage manifest — all 32 in-scope binaries, plus the 33 S11 omitted

Every file re-derived by me, independently of S11's proof files.

# File Bytes What I checked
1 assets/fonts/BigShouldersDisplay-Medium.ttf 68,592 SHA-256 (a9b588db…); 16 tables; full name dump — IDs [0,1,2,3,4,5,6,14,16,17,256,262], 13 absent; OS/2.fsType=0, achVendID=HoP; head.fontRevision 2.002; 754 glyphs / 718 cmap; instanced upstream VF at wght 500 → 718/718 widths identical; stripped IDs [8,9,11,12,13,25]; git add-commit; APK presence + hash
2 assets/fonts/BigShouldersDisplay-Bold.ttf 68,456 as above at wght 700; stripped IDs [8,9,11,12,13,16,17,25]
3 assets/fonts/BigShouldersDisplay-ExtraBold.ttf 68,628 as above at wght 800; stripped IDs [8,9,11,12,13,25]
4 assets/fonts/ChivoMono-Regular.ttf 59,412 SHA-256 (3bfc5e83…); name IDs [0,…,6,14,256,260,266,267], 13 absent; fsType=0, OMNI; rev 1.008; 800 glyphs / 642 cmap; VF instance at wght 400 → 642/642 widths identical; stripped IDs [7,8,9,11,12,13,16,17,25]; APK hash
5 assets/fonts/ChivoMono-Medium.ttf 59,404 as above; VF instance at wght 500 → 642/642 outlines point-identical
6 assets/fonts/ChivoMono-Bold.ttf 59,356 as above at wght 700
7 assets/fonts/DSEG7Classic-Bold.ttf 23,040 SHA-256 (d16181c4…); 14 tables incl. FFTM; name IDs [0,…,6,9,12,13,14,19]; ID 13 = complete OFL 1.1, 4,390 chars, byte-identical to the published licence; fsType=8; rev 0.46; 72 glyphs / 69 cmap; bit-identical to the official v0.46 release archive; RFN "DSEG" checked; APK hash
8-19 assets/audio/{beep,ping,bell,chime,marimba,buzz,chirp,coin,fanfare,pop,cascade,bowl}.wav 30,914 … 366,956 SHA-256; RIFF PCM 16-bit mono 44.1 kHz; regenerated byte-for-byte in a clean sandbox; generator line traced; git history incl. rewrite commits
20 android/app/src/main/res/raw/cadence_alarm.wav 142,928 as above (generator :232-236); git add f47f2e5 → rewrite 0c17267, pre-generator blob 0833a4f6 ≠ current 507f34e8; ships as res/pC.wav in the APK, hash matches
21 assets/audio/step.wav 54,728 SHA-256 (afbcee39…); blob c6afec8e unchanged across all history; confirmed absent from the generator output set; duration 0.620000 s exact; 11,279 exact-zero samples; f0 ≈ 986.5 Hz with 2nd harmonic at 0.0010 → near-pure sine
22 assets/audio/click-up.wav 28,268 as above; f0 1249.98 Hz; odd harmonics 1/n to 4 dp, even ≤0.0051; envelope peak at sample 441 = 10.000 ms, attack R²=0.99977, decay R²=0.99978; reconstruction attempted — max error 7 LSB, 2,053/14,112 samples differ, byte-equality NOT achieved
23 assets/audio/click-down.wav 28,268 as above; f0 820.07 Hz; envelope array byte-identical to click-up; peak and RMS identical; Pearson vs click-up = −0.0329
24 assets/icon/ic_foreground.png 14,073 SHA-256; 1024×1024 RGBA; every PNG chunk enumerated — IHDR, IDAT, IEND only, no XMP/EXIF/author; palette #000000α0 90.68%, #828A80, #E8600F; git; not in the Flutter asset bundle
25 assets/icon/ic_legacy.png 21,103 as above, RGB; #F4EFE4 85.43% matches adaptive_icon_background at pubspec.yaml:37
26 assets/icon/ic_monochrome.png 10,517 as above, RGBA, black-only
27 assets/logo/mark_white.png 8,089 SHA-256; 238×384 RGBA; no ancillary chunks; #E8600F and #828A80 byte-exact match with ic_legacy.png; the only icon-family file that ships in the Flutter bundle
28 web/favicon.png 917 SHA-256; chunks IHDR,sRGB,pHYs,iTXt(Adobe XMP),IDAT,IEND; BIT-IDENTICAL to flutter/flutter 3.44.8 template
29 web/icons/Icon-192.png 5,292 SHA-256; zTXt raw EXIF + tIME 2020-01-07 16:25:45; BIT-IDENTICAL to template
30 web/icons/Icon-512.png 8,252 as above, tIME 2020-01-07 16:26:38
31 web/icons/Icon-maskable-192.png 5,594 SHA-256; IHDR,IDAT,IEND only; upstream .img.tmpl is a 0-byte placeholder, but BIT-IDENTICAL to flutter_template_images 5.0.0
32 web/icons/Icon-maskable-512.png 20,998 as above; rendered and confirmed to be the Flutter logo
33-47 android/app/src/main/res/{mipmap-{h,m,xh,xxh,xxxh}dpi/ic_launcher.png, drawable-*dpi/ic_launcher_{foreground,monochrome}.png} 714 … 5,796 omitted by S11. SHA-256 for all 15; mipmap-xxxhdpi rendered → Cadence seven-segment mark in the #E8600F/#828A80/#F4EFE4 palette; confirmed regenerated from assets/icon/, not template. AUTHOR-ORIGINAL derivatives
48-62 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-*.png (15) 282 … 10,932 omitted by S11. SHA-256 for all 15; all BIT-IDENTICAL to flutter_template_images 5.0.0; 1024×1024 rendered → Flutter logo. THIRD-PARTY, see R-F1
63-65 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage{,@2x,@3x}.png 68 each omitted by S11. All three sha256 93ae7d49…, all BIT-IDENTICAL to the Flutter template
tools/build_ringtones.py 238 lines Read in full; executed in an isolated sandbox; all 13 outputs hash-compared; every cited generator line opened
pubspec.yaml 68 lines Read in full with line numbers; every S11 citation checked (3 defects, R-F4)
build/app/outputs/flutter-apk/app-release.apk 53,629,091 sha256 f11a484d…; 412 entries; all 7 .ttf extracted and hash-matched; NOTICES.Z decompressed and searched for 11 needles; FontManifest.json; res/pC.wav hash-matched; full entry scan for licence-named files
android/.../res/raw/keep.xml 6 lines Read in full; tools:keep="@raw/cadence_alarm" confirmed
README.md 38 lines Line 38 quoted and confirmed

Scoped areas where I found nothing: the PNG chunk scan across all nine assets/ and web/ images turned up no third-party author string, no copyright text, and no software record — four of them carry no ancillary chunks at all. No asset in the repository is a sampled recording. The OFL body reproduced in the draft is byte-perfect.

Stream S12: finding and refutation

S12 — Resource lifecycle, leaks, and kiosk endurancefindings/S12_lifecycle.md · raw .md

S12 — Resource lifecycle, leaks, and kiosk endurance

Subject: the app repository at 03a176e72ef0075eec86b8915cbe6e93042a3b9d (v0.4.12+18). All work done on a copy at a scratch working copy; cadence-app was never written to (R10). Every run recorded through proof/run_and_record.sh [not published] into proof/01_findings/S12/.

Usage model this stream audits against: the tablet is switched on before service and left running all day with the screen never sleeping (WakelockPlus.enable(), lib/main.dart:28), often plugged in, often hot.

Headline

Question Verdict
Confirmed undisposed resources 6 (S12-F1 … S12-F6)
Does anything grow without bound during a 12-hour service? Yes — one thing: the journal file. The engine, run map, clone list, Diag ring buffer and the persisted run payload are all structurally bounded and measured flat across 12 simulated hours.
Can the 150 ms tick double-start? The ticker can be orphaned — proven (S12-F1). Two ticks running at once additionally requires a second HomeScreen mount in one isolate; at this commit there is exactly one construction site (lib/main.dart:55), so it cannot happen today. The guard is the navigation graph, not the code.
Does the tick stop when nothing is running? No. It runs unconditionally and rebuilds 155 widgets every 150 ms on a completely idle board (S12-F7).
Does anything mitigate OLED burn-in? Nothing. Zero matches for brightness / dim / idle / pixel-shift across lib/ and android/app/src/main/ (S12-F8).
Do state and scheduled alarms survive a reboot? State: yes, by construction (absolute endsAt in SharedPreferences). OS alarms: yes by code path, UNVERIFIED (R3) on hardware. But nothing relaunches the app, so after a reboot the board is dark until a human taps the icon (S12-F9).

1. Resource table

Every Timer, AnimationController, AudioPlayer, TextEditingController, FocusNode, StreamController, StreamSubscription and platform-channel listener in lib/. Verified against the code map §3.4 and re-read at source. Line numbers valid at 03a176e.

Resource Created at Held in Disposed at Verdict
Timer.periodic 150 ms — the heartbeat lib/ui/home.dart:154 _HomeScreenState._ticker (:39) lib/ui/home.dart:238 LEAK on one path — created after four awaits; a teardown inside that window orphans it permanently (S12-F1)
Timer 900 ms / 700 ms / 260 ms — delayed announcements lib/ui/home.dart:277 not stored never LEAK (S12-F2)
Timer 260 ms — single/double tap disambiguation lib/ui/home.dart:387 _tapPending[id] (:59) lib/ui/home.dart:379, :239-241 OK — proven by w3_tap_window.txt
Timer 300 ms — backstop re-schedule debounce lib/alarm_backstop.dart:146-147 Backstop._debounce (:41) :145, :155 only; no dispose/close on Backstop LEAK (S12-F3)
Timer.periodic 60 s — journal heartbeat lib/journal.dart:106 Journal._beat (:31) lib/journal.dart:241, inside @visibleForTesting disableForTests() LEAK on re-init (S12-F4)
Timer.periodic 3 s — journal auto-flush lib/journal.dart:107 Journal._autoFlush (:32) lib/journal.dart:242, same LEAK on re-init (S12-F4)
Timer 300 ms — voice re-drain after an utterance lib/audio/voice.dart:177 not stored never Self-completing, bounded by the speech queue; no accumulation measured (see §3.4)
Timer 60 ms — voice re-drain after stopFor lib/audio/voice.dart:201 not stored never Same
AudioPlayer ×4 (click/chime pool) lib/audio/audio.dart:45-47 via _newPlayer() (:38) SoundBox._pool (:12) nowhere in lib/ LEAK (S12-F5)
AudioPlayer ×1 (dedicated ringtone) lib/audio/audio.dart:48 SoundBox._ring (:19) nowhere in lib/ LEAK (S12-F5)
AnimationController _pulse lib/ui/tile.dart:75 :65 lib/ui/tile.dart:147 OK
AnimationController _breath lib/ui/tile.dart:77 :66 :148 OK
AnimationController _jiggle lib/ui/tile.dart:79 :67 :149 OK
AnimationController _flash lib/ui/tile.dart:81 :68 :150 OK
AnimationController _appear lib/ui/tile.dart:83 :69 :151 OK
AnimationController _spawn lib/ui/tile.dart:85 :70 :152 OK
TextEditingController _name lib/ui/modals.dart:204 :193 :226, with removeListener at :225 OK
TextEditingController _phrase lib/ui/modals.dart:205 :194 :227 OK
WidgetsBindingObserver registration lib/ui/home.dart:73 the State itself lib/ui/home.dart:237 OK
ValueNotifier<Set<String>> Diag.critical lib/diagnostics.dart:26 static never OK by design — process-lifetime singleton, read through ValueListenableBuilder (lib/ui/home.dart:670) which unsubscribes itself
MethodChannel handler cadence/tts MainActivity.kt:65 / AppDelegate.swift:67 engine-scoped never removed OK — dies with the FlutterEngine
MethodChannel handler cadence/volume MainActivity.kt:43 / AppDelegate.swift:51 engine-scoped never removed OK — same
pendingSpeaks map of channel results MainActivity.kt:35, AppDelegate.swift:28 native completeSpeak / completeAllSpeaks LEAK on a wedged utterance (S12-F6)
FocusNode none exist. grep -rn "FocusNode(" lib/ → no output (proof/01_findings/S12/grep_timer_and_controller_sites.txt)
StreamController / StreamSubscription none exist. grep -rn "StreamController\|\.listen(" lib/ → no output (same file)
ScrollController none exist (same grep)

Totals: 21 disposable resources exist in lib/; 6 are undisposed on at least one reachable path; zero StreamController, StreamSubscription, FocusNode or ScrollController exist at all.


2. Findings

S12-F1 — Unmounting while _boot() is awaiting orphans the 150 ms ticker permanently

  • Severity: HIGH
  • Location: lib/ui/home.dart:154 (creation), lib/ui/home.dart:236-243 (dispose), lib/ui/home.dart:136,141,148,149 (the awaits it sits behind) (valid at 03a176e)
  • What is wrong: _ticker is assigned at the end of _boot(), behind four awaits — sounds.init() (:136), _initSystemVolume() (:148) and backstop.init() (:149), each of which is one or more platform-channel round trips. dispose() runs _ticker?.cancel() (:238) against a field that is still null inside that window. When the awaited reply lands, _boot() resumes on a defunct State and creates a Timer.periodic that no code holds a reference to and nothing can ever cancel. It keeps calling engine.tick() — which calls host.persistRun() and therefore keeps writing SharedPreferences and re-arming OS alarms — for the life of the isolate. The window is not theoretical: the app's own comments record the voice engine taking 5.7 s to come up on a cold start (lib/engine/engine.dart note and lib/audio/voice.dart:59-62).
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: an orphaned tick is a second engine writing over the first. If the screen is ever remounted (a second route, a hot restart, any future navigation) two ticks run concurrently, each calling persistRun() at 6.67 Hz against the same SharedPreferences keys, and each re-arming the OS backstop. The observable failure is a board that disagrees with itself and a battery that drains twice as fast, discovered at the worst possible time.
  • Proposed fix: 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 the fix: 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-F2 — Delayed announcement timers are never held and outlive dispose()

  • Severity: MEDIUM
  • Location: lib/ui/home.dart:275-281, specifically :277 (valid at 03a176e)
  • What is wrong: _announceIfStill creates a bare Timer and drops the handle on the floor. It is called three times per alarm cycle — 900 ms on fire (:294), 700 ms on each repeat (:309), 260 ms on a live step advance (:324). dispose() (:236-243) cancels _ticker and every _tapPending entry and nothing else, so an alarm that fires within one second of teardown leaves a timer running that will call engine.run[id] and voice.enqueue(...) against a disposed screen. It also retains the whole _HomeScreenState — and through it the Engine, the SoundBox and its five AudioPlayers — past disposal.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the tablet speaks a dish name for a board that no longer exists, and the memory the board was holding — including five audio players — is pinned until the process dies. On a device the OS is already reclaiming, that is the moment the app is least able to afford it.
  • Proposed fix: 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 the fix: 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-F3 — Backstop owns a 300 ms timer but has no disposal, and arms an OS alarm after teardown

  • Severity: MEDIUM
  • Location: lib/alarm_backstop.dart:41 (field), :146-147 (creation), and the absence of any dispose/close member on the class (:27-279); owner lib/ui/home.dart:38 (valid at 03a176e)
  • What is wrong: every deadline change to an already-armed timer schedules a 300 ms debounce (:143-148) that later calls _flushSchedules()_schedule()_plugin.zonedSchedule(...). Backstop exposes no way to release it, and _HomeScreenState.dispose() does not try. A +10/-10 press in the last 300 ms of the screen's life therefore arms an OS alarm after the screen that owned it is gone.
  • Evidence: 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)
  • Why it matters for a restaurant kitchen: a full-screen, alarm-category, Importance.max notification (lib/alarm_backstop.dart:44-59) armed by a screen that no longer exists is a phantom ring — exactly the outcome the file's own comment at :125-126 says it never risks.
  • Proposed fix: give Backstop a dispose() that cancels _debounce and clears _pending, and call it from _HomeScreenState.dispose(). Applied and verified on the copy — same patch file, proof/01_findings/S12/fix_announce_timer_leak.patch.
  • How to prove the fix: 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

  • Severity: MEDIUM
  • Location: lib/journal.dart:106-107; the only cancel site is :241-242, inside @visibleForTesting disableForTests() (valid at 03a176e)
  • What is wrong: init() assigns _beat and _autoFlush unconditionally. There is no production-reachable cancel path at all: disableForTests() is the sole one and it is annotated @visibleForTesting. Any second init() in the same isolate leaves the first 60 s heartbeat and the first 3 s auto-flush running forever, both writing to the same file and the same SharedPreferences key (:99, :152, :174). Today main() is the only caller (lib/main.dart:25), so this bites on a Flutter hot restart and on any future re-init; the class has no defence of its own.
  • Evidence: 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]
  • Why it matters for a restaurant kitchen: the journal is the only forensic record the pilot has. Two heartbeats writing the same cadence-journal-beat stamp makes the kill-detection arithmetic at :85-90 read from whichever timer wrote last, and doubles the flush rate against the tablet's flash for the rest of the session.
  • Proposed fix: 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 the fix: 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.

S12-F5 — The five AudioPlayers are never released; SoundBox and VoiceBox have no disposal at all

  • Severity: MEDIUM
  • Location: lib/audio/audio.dart:10-116 (whole class; players created at :45-48), lib/audio/voice.dart:24-204 (whole class); owners lib/ui/home.dart:36-37 (valid at 03a176e)
  • What is wrong: SoundBox.init() constructs four pooled AudioPlayers and one dedicated ringtone player. AudioPlayer holds a native player handle and a platform event stream. Neither SoundBox nor VoiceBox declares a dispose, release or stop member, and _HomeScreenState.dispose() does not call one. grep -rn "\.release()\|\.dispose()" lib/audio/ returns nothing.
  • Evidence: 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/.
  • Why it matters for a restaurant kitchen: on Android each AudioPlayer holds a MediaPlayer and an AudioAttributes-configured session on STREAM_ALARM. The platform limit on concurrent MediaPlayer instances is device-specific and small; leaking five per screen lifetime is survivable at one screen, but it also means the app never voluntarily gives the alarm stream back when it is torn down. Combined with S12-F2 the players are additionally kept alive by a retained State.
  • Proposed fix: 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().
  • How to prove the fix: proof/01_findings/S12/tests/s12_w5_dispose_scope_test.dart. Invert its two expectations to isNotEmpty — red now, green after.

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

  • Severity: MEDIUM
  • Location: android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt:35,143-165, ios/Runner/AppDelegate.swift:28,173, and the Dart timeout at lib/audio/voice.dart:166-173 (valid at 03a176e)
  • What is wrong: speak() stores the MethodChannel.Result in pendingSpeaks keyed by utterance id and removes it only from completeSpeak (an engine callback) or completeAllSpeaks (only reached by the stop method). The Dart side abandons an utterance after 12 s (voice.dart:168) and then simply moves on — it never sends stop, because stopFor only does so when _currentId == id (voice.dart:193-202) and by then _currentId has been cleared. A vendor TTS engine that swallows a callback therefore leaves one map entry per occurrence, for the life of the activity.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: the app's own comment at MainActivity.kt:158-159 states that every pending speak must complete or Dart's queue hangs. The 12 s timeout protects Dart but abandons native, so the failure the comment warns about becomes a slow native-side leak on exactly the devices with the flakiest TTS engines — the cheap kitchen tablets.
  • Proposed fix: 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 the fix: 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-F7 — The 150 ms tick rebuilds the entire screen unconditionally, including when nothing is running

  • Severity: HIGH
  • Location: lib/ui/home.dart:154-167, specifically the unguarded setState(() {}) at :166; aggravated by the absence of any RepaintBoundary in lib/ (valid at 03a176e)
  • What is wrong: the heartbeat calls engine.tick() then setState(() {}) on every tick with no condition on whether any timer is running. setState on _HomeScreenState marks the whole subtree dirty — Header, the critical banner, the LayoutBuilder, and all seven TileViews with their merged AnimatedBuilders. Measured: 155 widget builds per tick with zero timers running, i.e. 1,033 widget builds per second, sustained for the whole service, on a board where nothing is changing except a clock. Over a 12-hour service that is ~44.6 million widget builds. There is no RepaintBoundary anywhere to stop the repaint propagating either — S4's recorded grep proof/01_findings/S4/grep_semantics_repaint.txt searched RepaintBoundary|Semantics|semanticLabel|excludeSemantics|MergeSemantics|tooltip|ExcludeSemantics across lib/ and exited 1 with no output.
  • Evidence: 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 Scaffold(...) ... 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.
  • Why it matters for a restaurant kitchen: the wakelock is held for twelve hours (lib/main.dart:28), so this rebuild rate is the app's floor power draw, not its peak. A tablet that is plugged in and hot has no thermal headroom to spare, and sustained CPU at 6.67 Hz with a full-tree rebuild is the difference between a board that is responsive at 21:00 and one that is thermally throttled. It also runs identically during the six hours between services when the board is idle.
  • Proposed fix: 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.
  • How to prove the fix: 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.

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

  • Severity: MEDIUM
  • Location: lib/main.dart:28 (WakelockPlus.enable()), lib/main.dart:33 (SystemUiMode.immersiveSticky), lib/ui/theme.dart:5-27 (the palette), lib/ui/grid_layout.dart:1-109 (deterministic tile geometry) (valid at 03a176e)
  • What is wrong: the screen is pinned on for the whole service, the layout is a pure function of the tile count (GridLayout.solve), and every tile carries a fixed uppercase dish name in a fixed position plus the DSEG7 88:88 / 8:88 LCD ghost at a fixed position. The background is near-white — C.bg = 0xFFF4EFE4, C.tileIdle = C.track = 0xFFE7DED0 (lib/ui/theme.dart:5,17,26) — against a permanently dark header bar C.headerBg = 0xFF191B14 (:22). There is no dimming, no idle state, no pixel shifting, no screensaver and no brightness control anywhere in the app or in the Android resources.
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: the commercial consequence is not a crash, it is a warranty complaint three to six months after the sale. A restaurant that leaves the tablet on the pass twelve hours a day, six days a week, is accumulating roughly 3,700 hours a year of an unchanging high-luminance image. On an OLED panel the dark header bar and the fixed dish labels will ghost. The customer will not blame the panel, they will blame Cadence — and the ghost image will be of our header. A near-white background is also the maximum-power case on OLED, which compounds S12-F7.
  • Proposed fix: 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 the fix: 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.

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

  • Severity: HIGH
  • Location: android/app/src/main/AndroidManifest.xml:22-43 (the only activity, with a MAIN/LAUNCHER filter at :40-41 and no boot filter) and :48-56 (the plugin's boot receiver) (valid at 03a176e)
  • What is wrong: RECEIVE_BOOT_COMPLETED is declared and it is genuinely wired — the manifest registers com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver for BOOT_COMPLETED, MY_PACKAGE_REPLACED and both QUICKBOOT actions, and that receiver calls FlutterLocalNotificationsPlugin.rescheduleNotifications(context), which reloads the plugin's own scheduled_notifications SharedPreferences cache and re-arms each alarm through zonedScheduleNotification with its stored scheduleMode. So the alarms come back. What does not come back is Cadence: MainActivity has only a MAIN/LAUNCHER intent filter, and no code in the app starts it at boot. Engine state survives independently and correctly — every deadline is an absolute epoch millisecond in SharedPreferences (lib/engine/models.dart:97, lib/engine/store.dart:151-152) and Engine.tick() catches up through every crossed boundary (lib/engine/engine.dart:313-329) — but nothing reads it until a human taps the icon.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: a kitchen loses power for four seconds during service. The tablet reboots to its launcher. The pass now shows a home screen, not a board — no cook is going to notice that Cadence is not the app in front of them until a dish burns. The OS alarms will still ring, which is the safety net working, but the product is gone until someone re-opens it.
  • Proposed fix: 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 the fix: the four-capture dumpsys/reboot protocol above, plus a fifth capture showing the Cadence board on screen after the reboot with no human interaction.

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

  • Severity: MEDIUM
  • Location: lib/ui/home.dart:448 and :456 (after await showTimerEditor at :421), lib/ui/home.dart:491 (after await showSettings at :467) (valid at 03a176e)
  • What is wrong: _openEditor and _openSettings both await a modal and then call setState(() {}) unconditionally. Every other post-await setState in the file is guarded — :166, :168, :191, :398 all read if (mounted). These three are not. State.setState after disposal throws: the lifecycle check is an assert (debug only), but in release _element is already null and markNeedsBuild() fails. If the OS tears the activity down while the editor sheet is open — which is precisely when the operator has walked away from the tablet — the app crashes on return instead of rebuilding.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the editor is open exactly when a cook is programming a new dish mid-service. A crash there loses the definition being typed and takes the board down with it while other timers are running.
  • Proposed fix: if (!mounted) return; immediately after each await, matching the pattern already used at :166, :168, :191, :398.
  • How to prove the fix: 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.

3. Soak test — measured growth over a simulated full service

The engine and store are pure Dart, so a full 12-hour service was driven at the real 150 ms tick resolution. Source: proof/01_findings/S12/tests/s12_soak_test.dart. Raw output: proof/01_findings/S12/soak_engine_journal.txt.

Exact command (from a scratch working copy, with CADENCE_REPO=the app repository):

proof/run_and_record.sh [not published] \
  proof/01_findings/S12/soak_engine_journal.txt \
  flutter test test/s12_soak_test.dart -r expanded

3.1 The service that was simulated (SOAK A1)

Seven dishes with the production seed durations (45 / 135 / 260 / 375 / 210 / 720 s and the 3-step 810 s chain, lib/engine/store.dart:326-343); a dish goes on every 45 s; the cook acknowledges each alarm 12 s after it rings; every fifth start also spawns a batch clone. 12 hours, 288,001 ticks.

ticks=288001  wall=177 ms  us_per_tick=0.6
starts=960 alarms=1145 repeats=1145 stepAdvances=120 stops=1145 clonesSpawned=192 cloneRefused=0
persistRun=4707 persistDefs=0 persistClones=382
run JSON bytes: last=718 peak=948 cumulative_written=3221297
journal lines written by host=4707  journal file bytes=331022

3.2 Hourly growth checkpoints

runJSON B is the size of the payload Store.saveRun writes to SharedPreferences on every mutation; journal B is the on-disk journal file.

h timers runs clones Diag.log critical runJSON B journal B fires repeats persistRun jLines
0 7 0 0 0 0 0 167 0 0 0 0
1 7 5 1 0 0 520 26910 91 91 379 379
2 7 5 1 0 0 520 54529 187 187 773 773
3 7 8 2 0 0 841 81520 280 280 1156 1156
4 7 6 2 0 0 619 109580 378 378 1557 1557
5 7 8 1 0 0 821 137151 473 473 1948 1948
6 7 7 2 0 0 726 165175 571 569 2345 2345
7 7 5 1 0 0 520 193067 667 667 2743 2743
8 7 6 1 0 0 619 220457 762 762 3134 3134
9 7 7 2 0 0 742 247950 857 857 3527 3527
10 7 7 2 0 0 718 275560 953 953 3919 3919
11 7 5 1 0 0 500 303823 1052 1052 4321 4321
12 7 7 2 0 0 718 331022 1145 1145 4707 4707

Reading: timers, runs, clones, Diag.log, critical and runJSON B are flat across twelve hours. The run map oscillates between 5 and 8 live entries and never approaches its structural ceiling of 21 (7 dishes × Engine.maxBatch = 3). The persisted run payload stays between 500 and 948 bytes. The Diag ring buffer stays at 0 because nothing failed; its cap is 50 by construction (lib/diagnostics.dart:21,30). Exactly one column grows monotonically: journal B.

A heavier variant (SOAK A2, a dish every 20 s, journal off, 1,429 starts and 1,897 alarms) confirms the same ceilings hold under three times the load: timers=7 runs=8 clones=3, run-payload peak 1,245 bytes.

3.3 The journal — the one unbounded thing (measurement handed to S2)

journal growth: 27585 bytes/hour of service;
3 MB boot-rotation cap reached after 114.0 service hours

and, driving it past the cap inside one session (SOAK A3):

=== S12 SOAK A3 — journal rotation ===
after 60000 in-session lines: 6360167 bytes (6.07 MB); Journal._maxBytes is 3 MB
after a relaunch (init): 1090055 bytes (1.04 MB)

Numbers for S2, who owns the journal-rotation analysis: 27,585 bytes/hour of active service; 331,022 bytes for one 12-hour service; the 3 MB cap is crossed after 114 service hours, i.e. about 9.5 twelve-hour days of a tablet that is never restarted; and a session that does cross it reached 6.07 MB with no in-session trim, dropping to 1.04 MB only on the next init(). The rotation defect itself is S2's finding (findings/S2_persistence.md, the _maxBytes-checked-only-at- init item) — not restated here.

Two secondary I/O rates from the same run, also for S2: 4,707 Journal.log calls in 12 hours, each of which performs a writeAsString(..., flush: true) — a real fsync — plus a prefs.setInt (lib/journal.dart:139, :170-174); and 4,707 Store.saveRun calls writing 3.22 MB cumulative to SharedPreferences (cumulative_written=3221297).

3.4 Voice queue

=== S12 SOAK A4 — voice queue ===
pending after 5000 enqueues with a dead engine: 5000

The queue has no size cap of its own — only a time cap. _dropStale (lib/audio/voice.dart:180-188) removes entries older than staleMs = 20000, so the bound is "everything enqueued in any 20-second window". In production _announceIfStill enqueues at most one phrase per alarm fire and one per repeat, and repeats floor at minVoiceGapMs = 2000 per ringing timer (lib/engine/engine.dart:54,293); with the hard ceiling of 21 concurrent runs that is at most 21 × 10 = 210 entries in any 20-second window. Bounded — not a leak, but the bound is a consequence of maxBatch, not of anything the queue enforces.

The two unstored re-drain timers (voice.dart:177, :201) were probed with the timer-recording Zone across 200 enqueue/stop cycles and created zero surviving timers (SOAK A6) — they self-complete.

3.5 Mutation check (R8)

The soak is a real instrument, not a passing shell. Two mutations, both saved at proof/01_findings/S12/mutation_stoptimer_keeps_clone.patch, raw output at proof/01_findings/S12/mutation_soak_goes_red.txt:

  1. Engine.stopTimer no longer dissolves a stopped batch clone (lib/engine/engine.dart:219-223).
  2. Journal._rotate becomes a no-op (lib/journal.dart:192).

Result — three of the six soak tests go red, and green again on the pinned source:

00:00 +0 -1: SOAK A1 ... Expected: empty  Actual: [ ...  no orphan clone may survive a stop
00:01 +0 -2: SOAK A2 ... Expected: empty  Actual: [ ...  no orphan clone may survive a stop
00:01 +0 -3: SOAK A3 ... after a relaunch (init): 6360485 bytes (6.07 MB)
             Expected: a value less than <6360167>  Actual: <6360485>

lib/ was restored byte-for-byte afterwards (diff -rq lib <pinned> → identical).


4. The 150 ms tick — the three questions, answered

Does it run when no timer is active? Yes. Timer.periodic at lib/ui/home.dart:154 has no condition, and engine.tick() + setState(() {}) at :165-166 run on every fire regardless of engine.run being empty. Measured cost with an empty run map: 155 widget rebuilds per tick (§S12-F7). Verdict: the tick never stops while the screen is mounted.

Is it ever double-started? The ticker can be orphaned — proven, S12-F1. For two ticks to run concurrently a second _HomeScreenState must exist in the same isolate. Recorded grep proof/01_findings/S12/grep_homescreen_mount_sites.txt:

lib/ui/home.dart:25:  const HomeScreen({super.key, required this.store});
lib/main.dart:55:      home: HomeScreen(store: store),

One construction site, one route, no Navigator.push anywhere. At 03a176e a concurrent double tick cannot occur. It is prevented by the navigation graph, not by the ticker code; adding any second route re-opens it. Twenty-five pausedhiddenresumed cycles do not create one either — recorded run proof/01_findings/S12/w4_lifecycle.txt passes the binding's pending-timer assertion after a single _ticker?.cancel():

=== S12 W4 — 25 pause/hidden/resume cycles ===
onForeground() -> getActiveNotifications calls: before=0 after=25 (one per real resume)
total platform calls: 55
zonedSchedule calls: 1  cancel calls: 0
EXIT_CODE=0

Does it stop when it should? It stops on dispose() (:238) — proven green by w2_dispose.txt and w3_tap_window.txt — except on the boot-race path of S12-F1. It does not stop when the app is backgrounded: didChangeAppLifecycleState (lib/ui/home.dart:175-204) sets _foreground = false and flushes the journal but never touches _ticker. That is defensible (the engine must still fire alarms while alive in the background, lib/ui/home.dart:297-302), and the OS suspends the isolate anyway; it is recorded here as intentional, not as a defect.


5. Lifecycle transitions

didChangeAppLifecycleState (lib/ui/home.dart:175-204) handles four states:

State Handling Verdict
resumed de-duplicated by _foreground (:180); resets _lastTickMs so the pause is not logged as a freeze (:187), calls engine.tick() to fire anything that expired (:188), backstop.onForeground() (:189), backstop.sync() (:190) Correct. engine.tick() recomputes from absolute endsAt — it cannot double-schedule, because _fireAlarm sets endsAt = null and status = ringing (lib/engine/engine.dart:280-281), so a second tick in the same millisecond takes the ringing branch instead. Proven by 25 cycles in w4_lifecycle.txt producing exactly 25 getActiveNotifications and no extra zonedSchedule.
paused / hidden de-duplicated by !_foreground (:194); backstop.onBackground() (:199) then Journal.flushNow() (:200) Correct. The de-duplication is necessary — Android fires both.
detached Journal.markCleanExit() (:202) Correct, and it is what makes the kill-detection at lib/journal.dart:85-90 meaningful.
inactive not handled — falls through every branch Neutral. On Android inactive is transient (a notification shade pull); treating it as backgrounded would mis-log. No defect.
OS reclaims memory and recreates the activity main() re-runs in a new FlutterEngine and a new isolate; state is reloaded from SharedPreferences and reconciled (lib/engine/store.dart:63-87, lib/engine/engine.dart:74-100) Correct by construction. The one hazard is S12-F4: a second Journal.init() in the same isolate would leak two periodic timers. Activity recreation gives a new isolate, so this is a latent rather than an active defect today.

6. Battery and thermal

Measured, on this workstation (Apple Silicon, flutter test host — not the target tablet):

Quantity Measured value Source
Engine.tick() cost 0.6 µs per tick, 177 ms for all 288,001 ticks of a 12-hour service soak_engine_journal.txt
Widget rebuilds per tick, idle board 155 w1_idle_tick_rebuild_cost.txt
Widget rebuilds per second 1,033 derived: 155 × 1000/150
Widget rebuilds per 12-hour service ≈44.6 million derived: 1,033 × 43,200
RepaintBoundary count in lib/ 0 proof/01_findings/S4/grep_semantics_repaint.txt, exit 1, no output
SharedPreferences run-payload writes 4,707 writes, 3.22 MB cumulative per 12-hour service soak_engine_journal.txt
Journal fsyncs 4,707 per 12-hour service (flush: true, lib/journal.dart:170-171) soak_engine_journal.txt

Consequence: the engine is free; the cost is the unconditional full-tree rebuild at 6.67 Hz with no repaint boundaries, held for twelve hours behind a wakelock on a device with no thermal headroom. That is S12-F7.

UNVERIFIED (R3) — battery percentage. No battery figure is stated, because measuring one needs the target hardware, which we do not have (flutter doctor reports no Android SDK and no connected Android device, proof/00_baseline/SUMMARY.md §2). Exact protocol that would settle it: install the release APK on the target tablet; adb shell dumpsys batterystats --reset; leave the app foreground with an empty board, unplugged, screen on, for 60 minutes; adb shell dumpsys batterystats --charged dev.sergemio.cadence and record the app's mAh; repeat with the S12-F7 fix applied; report the delta. Store both raw dumpsys captures. Until then the claim in this section is a rebuild count, not a power figure.


7. Coverage manifest

Every file inspected for this stream, with what was checked.

7.1 lib/ — 18 files, 4,853 lines (complete)

File Lines What S12 checked
lib/main.dart 58 Wakelock call site (:28) and its unawaited .then/.catchError chain; immersiveSticky (:33); Journal.init single call site (:25); confirmed no timer/controller/dispose in the file; confirmed HomeScreen is constructed exactly once (:55)
lib/ui/home.dart 722 Whole file. Ticker creation/cancel; _boot() await chain and the dispose race; _announceIfStill; _tapPending map; WidgetsBindingObserver add/remove; all four AppLifecycleState branches; every setState/mounted/await triple; Journal.snapshot closure retention; per-tick rebuild scope measured on the live widget
lib/engine/engine.dart 432 Whole file. tick() cost and allocation profile under a 12-hour soak; viewList() per-tick allocation; growth of timers/run/clones under 960 and 1,429 starts; maxBatch ceiling; stopTimer clone dissolution (mutated to prove the soak detects growth); reconcile() invariants; confirmed no timers, streams or controllers
lib/engine/store.dart 354 Whole file. saveRun/saveDefs/saveClones payload sizes across 12 hours; write frequency (4,707 saveRun per service); _write/_guard unawaited-future error routing; confirmed no disposable resources; seed durations used to build the soak service
lib/engine/models.dart 160 Whole file. Absolute endsAt epoch-ms design (the reason state survives reboot); JSON round-trip size per run entry; confirmed no disposable resources
lib/journal.dart 250 Whole file. Both Timer.periodic creation sites and the single cancel site; _buf/_chain growth; _flush fsync-per-log; file growth measured hourly to 12 h and past the 3 MB cap; _rotate reachability (mutated); double-init() timer census via the Zone probe
lib/alarm_backstop.dart 279 Whole file. _debounce creation/cancel sites and the absent dispose; _scheduled map growth across 4,707 syncs; cancelAll on init; onForeground/onBackground call counts across 25 lifecycle cycles; the boot-receiver claim in the header comment traced to the plugin source
lib/audio/audio.dart 116 Whole file. Five AudioPlayer creations counted at the platform channel; absence of any release/dispose; _pool round-robin index bounds; _canVibrate probe
lib/audio/voice.dart 204 Whole file. _queue growth with a dead engine (5,000 enqueues) and the staleMs time bound; both unstored re-drain timers probed with the Zone; _gen generation counter; the 12 s timeout path and its native-side consequence
lib/audio/alarm_volume.dart 68 Read in full. Pure value object — confirmed no timers, listeners or disposable state
lib/diagnostics.dart 54 Whole file. log ring-buffer cap of 50 verified flat across a 12-hour soak; _warned set bounded by literal scope names; critical ValueNotifier never disposed (correct for a static singleton) and its single consumer's auto-unsubscribe
lib/i18n.dart 167 Read in full. Confirmed no timers, controllers or retained listeners; lang setter mutates in place, no allocation growth
lib/ui/tile.dart 819 Lines 1-200 read verbatim (state, initState, _syncLoops, didUpdateWidget, dispose, build); all six AnimationControllers traced creation→dispose; addPostFrameCallback mounted guard at :90; AnimatedBuilder/Listenable.merge rebuild scope measured live; remainder scanned for further resource creation (none)
lib/ui/modals.dart 746 Lines 150-230 and 585-746 read verbatim; both TextEditingControllers traced creation→removeListener→dispose; _SettingsState._sendJournal mounted guard at :715; whole file grepped for Timer/FocusNode/ScrollController/addListener (only the two controllers)
lib/ui/header.dart 215 Read for resources. Stateless; clock renders from the now passed down by the parent's tick — no timer of its own. Counted among the 155 per-tick rebuilds
lib/ui/grid_layout.dart 109 Read in full. Pure geometry, deterministic for a given tile count — the basis of the burn-in finding (fixed pixel positions)
lib/ui/theme.dart 82 Read in full. Palette values quoted for the burn-in and power analysis (C.bg, C.tileIdle, C.track, C.headerBg)
lib/ui/logo.dart 18 Read in full. Image.asset only, no state, no resources

7.2 Platform and configuration files

File Lines What S12 checked
android/app/src/main/AndroidManifest.xml 79 RECEIVE_BOOT_COMPLETED; both plugin receivers and their intent filters; MainActivity's intent filter (MAIN/LAUNCHER only — the S12-F9 gap); configChanges (why rotation does not recreate the activity); WAKE_LOCK
android/app/src/main/kotlin/dev/sergemio/cadence/MainActivity.kt 176 Whole file. pendingSpeaks/pendingInits growth paths; utterSeq; onDestroy TTS shutdown; channel handlers never removed (engine-scoped, correct)
ios/Runner/AppDelegate.swift 207 Resource-lifecycle scan. Same pendingSpeaks pattern (:28, :173) with the same wedged-utterance exposure; AVSpeechSynthesizer held for the app lifetime
flutter_local_notifications 22.1.0 Android source ScheduledNotificationBootReceiver.java in full; FlutterLocalNotificationsPlugin.java:227-238 (rescheduleNotifications), :517-537 (the prefs cache), :589-612 (zonedScheduleNotification) — the evidence behind the S12-F9 reboot verdict

7.3 Areas checked that produced NO finding

  • StreamController, StreamSubscription, FocusNode, ScrollController — none exist anywhere in lib/. Proof: proof/01_findings/S12/grep_timer_and_controller_sites.txt.
  • AnimationController disposal — all six in _TileViewState are disposed (lib/ui/tile.dart:146-153); no controller is created outside initState.
  • TextEditingController disposal — both are disposed and the one listener is removed (lib/ui/modals.dart:224-228).
  • WidgetsBindingObserver — added at :73, removed at :237.
  • Engine / run map / clone list / Diag ring buffer / persisted run payload growth — measured flat across two independent 12-hour soaks at two load levels (§3.2).
  • Voice queue growth — bounded by the 20 s stale window and the maxBatch ceiling (§3.4).
  • setState after dispose — every occurrence audited; four are guarded, three are not (S12-F10).
  • Lifecycle double-scheduling on resume — impossible by the endsAt = null / ringing state transition; verified over 25 cycles (§5).

7.4 Artefacts produced

Path (under proof/01_findings/S12/) Contents
soak_engine_journal.txt The 12-hour soak, all six SOAK tests, EXIT_CODE=0
w0_ticker_live_and_announce_timer_leak.txt Ticker proven live + the S12-F2 leak, EXIT_CODE=1 (red by design)
w1_idle_tick_rebuild_cost.txt 155 rebuilds per idle tick, full rebuild log
w2_dispose.txt, w3_tap_window.txt Clean mount/interact/unmount, EXIT_CODE=0
w4_lifecycle.txt 25 pause/hidden/resume cycles, EXIT_CODE=0
w5_dispose_scope.txt 5 AudioPlayer creates, 0 releases
w6_backstop_debounce.txt OS alarm armed after unmount
w7_boot_race.txt Orphaned 150 ms periodic ticker, EXIT_CODE=1 (red by design)
w0_AFTER_fix_green.txt Same tests green with both fixes applied, EXIT_CODE=0
baseline_suite_in_copy.txt, baseline_suite_AFTER_fix.txt The app's own 123 tests, green before and after the fixes
fix_announce_timer_leak.patch The S12-F2 + S12-F3 fix, as applied and verified
mutation_stoptimer_keeps_clone.patch, mutation_soak_goes_red.txt R8 mutation proof that the soak detects growth
grep_*.txt (6 files) Burn-in mitigation (empty), dispose sites, timer/controller sites, journal cancel sites, boot receiver, HomeScreen mount sites, setState/mounted pairs
tests/ (10 .dart files) Every test written for this stream, reproducible against a fresh copy
S12 REFUTE — resource lifecycle, leaks, kiosk enduranceagent_reports/S12_refute.md · raw .md

S12 REFUTE — resource lifecycle, leaks, kiosk endurance

Refuter for stream S12. Governing rule: R5. Subject the app repository at 03a176e72ef0075eec86b8915cbe6e93042a3b9d, read-only. All experiments on a scratch working copy, an independent copy made from the pinned tree and turned into its own git repository so run_and_record.sh stamps that tree's state, not the workspace root's. Every run recorded into proof/01_findings/S12_refute/.

Copy provenance, verified before any experiment:

$ diff -rq lib  <pinned>/lib   && diff -rq android <pinned>/android && diff -rq ios <pinned>/ios
COPY_SOURCE_IDENTICAL_TO_PINNED
$ git status --porcelain            # after every mutation was reverted
?? test/s12r_*.dart  (my test files only — lib/, android/, ios/ untouched)

Headline

Question Verdict
S12 findings reviewed 10 — every file:line S12 cited is valid at 03a176e; none was fabricated
CONFIRMED as written 3 (S12-F2, S12-F6, S12-F9)
REFUTED in part 7 (S12-F1, F3, F4, F5, F7, F8, F10) — the code fact holds in all seven; the harm claim, the classification, or the severity does not
Of the six claimed undisposed resources, how many are live leaks 1 (S12-F6), and it is narrower than stated
…how many are latent 5 (S12-F1, F2, F3, F4, F5) — dispose() has exactly one production trigger, and it destroys the isolate that owns every leaked object
Journal growth — reconciled 242,270 bytes/day ≈ 237 KiB/day, 3 MiB cap crossed after 13 days. Both prior figures are model numbers that omit four of the six journal emitters.
Does exactly one thing grow without bound? No. Three Map<String,int> in _HomeScreenState grow live, with no teardown required (S12R-F1). S12's headline is wrong.
Is the idle rebuild rate a real cost? Real but mis-sized. 155 builds/tick reproduces; it also reaches layout (16 marks) and paint (2 marks). Net measured cost 1,766 µs/tick falling to 7.5 µs with the guard — a 235× reduction. No power figure; HIGH is not supported.
Does the burn-in finding depend on a wrong panel assumption? It depends on an unestablished one. Nothing in the repo or in the audit records the target panel technology.
Findings contributed 6 (S12R-F1 … S12R-F6)

1. PRIORITY — the sevenfold contradiction, reconciled

1.1 Both measurements are reproducible, and both are wrong for the same reason

S12's soak reproduces byte-for-byte on my independent copy (proof/01_findings/S12_refute/repro_s12_soak.txt):

ticks=288001  wall=171 ms  us_per_tick=0.6
starts=960 alarms=1145 repeats=1145 stepAdvances=120 stops=1145 clonesSpawned=192
journal lines written by host=4707  journal file bytes=331022
journal growth: 27585 bytes/hour of service; 3 MB boot-rotation cap reached after 114.0 service hours

S2's arithmetic is internally sound. The two do not contradict on arithmetic — they contradict on service intensity, and both under-count on the same axis.

grep -rn "Journal.log(" lib/ returns 43 call sites in six files (proof/01_findings/S12_refute/grep_journal_log_sites.txt). S12's SoakHost and S2's cycle model both replicate only the lib/ui/home.dart sites. The four per-dish-cycle emitters both streams omitted:

Emitter Fires Measured bytes
lib/alarm_backstop.dart:197 ' secours' alarme systeme posee once per timer start (via persistRunsync_schedule) 95
lib/alarm_backstop.dart:228 ' secours' alarme systeme annulee once per fire and per stop (the timer leaves _desired) 77
lib/audio/voice.dart:165 ' parole' "<phrase>" once per announcement — fire, repeat, and live step advance 55
lib/audio/alarm_volume.dart:62 'volume' niveau reimpose on the rising edge of every ring (persistRun_reconcileAlarmVolume) 70

Measured line costs, one Journal.log call each, diffed against the real file (proof/01_findings/S12_refute/r2_line_cost.txt):

| source | bytes |
| home.dart:358  depart | 53 |
| alarm_backstop.dart:197  secours posee | 95 |
| alarm_backstop.dart:228  secours annulee | 77 |
| alarm_volume.dart:62  volume | 70 |
| home.dart:289  ALARME | 106 |
| voice.dart:165  parole | 55 |
| home.dart:307  rappel | 63 |
| home.dart:368  ARRET | 74 |
| home.dart:316  etape | 48 |
| home.dart:408  lot | 88 |
| journal.dart:148  battement | 67 |
| home.dart:648  reglage +10 | 53 |

one dish cycle, 1 repeat  — PRODUCTION (all emitters): 648 B
one dish cycle, 1 repeat  — S12 model (home.dart only): 296 B
one dish cycle, 3 repeats — PRODUCTION (all emitters): 884 B
one dish cycle, 3 repeats — S2 model (279 B claimed):  422 B

S2's own claimed 279 B per three-repeat cycle is 884 B in production — 3.2×.

1.2 The same engine simulation, with the omission put back

proof/01_findings/S12_refute/r1_journal_reconcile.txt drives the real Engine and the real Backstop (notification plugin mocked) over S12's exact 12-hour profile, twice:

--- P-S12  dish every 45 s, ack 12 s [home.dart lines only, = S12 model] ---
  starts=960 fires=1145 repeats=1145 steps=120 persistRun=4707
  journal lines: home=4707 extra(voice/volume)=0 beatNow calls=720
  journal bytes=383743  => 31965 bytes/hour

--- P-S12  dish every 45 s, ack 12 s [FULL production emission] ---
  starts=960 fires=1145 repeats=1145 steps=120 persistRun=4707
  journal lines: home=4707 extra(voice/volume)=2410 beatNow calls=720
  journal bytes=664960  => 55399 bytes/hour  54.10 KiB/hour
  DELTA: full emission is 1.73x the home.dart-only model S12 measured

At S12's own profile the production figure is 55,399 B/h, 2.0× the 27,585 B/h S12 reported. (My home-only replica lands at 31,965 rather than 27,585 because I carry the full lot line nouveau X (×n au total) from home.dart:408-412, which S12's host truncated. The 1.73× delta is measured between two runs of the same replica, so it is unaffected.)

One stated imprecision: the secours posee line prints dans ${(inMs/1000).round()} s, and the simulated clock runs ahead of the real one, so that field carries five digits instead of three — about 2 bytes on a 95-byte line over ~1,152 lines, 0.35 % of the 12-hour total.

1.3 Rate by service intensity — full production emission

proof/01_findings/S12_refute/r1_journal_reconcile.txt, R2:

intensity bytes/hour KiB/hour
I-PEAK — a dish every 45 s, acknowledged in 12 s 54,541 53.26
I-BUSY — a dish every 90 s, acknowledged in 15 s 33,771 32.98
I-STEADY — a dish every 180 s, acknowledged in 20 s 20,841 20.35
I-QUIET — a dish every 600 s, acknowledged in 20 s 6,696 6.54
I-IDLE — board on, nothing cooking 804 (derived, see below) 0.79

The idle floor is derived rather than soaked: Journal._heartbeat (journal.dart:145) writes a line only when the snapshot changed or beatQuietMs has elapsed, and it measures that window against DateTime.now() — the real wall clock, not the simulated one. A soak that compresses 12 hours into one second therefore never reaches the quiet window and reports 68 B/h. In the field the snapshot on an idle board never changes, so exactly one beat is written per 5 minutes: 12 × 67 B = 804 B/h. This is a defect in both soaks' idle numbers, mine included, and it is why the floor is computed from the measured 67-byte beat line instead of read off the soak.

1.4 The single reconciled figure

242,270 bytes/day = 237 KiB/day. The 3 MiB boot-rotation cap is crossed after 13 days of a tablet that is never restarted.

Assumptions it depends on, all stated:

  • Two services a day. Each service is 1 hour at peak (a dish every 45 s) and 2 hours steady (a dish every 3 min). → 2 × 54,541 + 4 × 20,841 = 192,446 B
  • Six further hours with the board on and lightly used (a dish every 10 min). → 6 × 6,696 = 40,176 B
  • Twelve hours with the board on and nothing cooking — the kiosk premise, WakelockPlus.enable() at lib/main.dart:28. → 12 × 804 = 9,648 B
  • Alarms acknowledged in 12–20 s, which is 1–3 repeats per ring at the engine's firstVoiceGapMs = 7000 / voiceGapFactor = 0.72 / minVoiceGapMs = 2000 (lib/engine/engine.dart:53-55).
  • The voice engine is healthy, so every announcement produces its ' parole' line.

Per hour of actual service that is 32,074 B/h ≈ 31 KiB per service hour.

Range, with the profile at each end:

End Profile bytes/day days to 3 MiB
Low quiet bistro: 2 services × 2 steady hours, 8 quiet hours, 12 idle 146,580 21.5
Reconciled as stated above 242,270 13.0
High S12's premise taken literally: 12 straight hours at peak, 12 idle 664,140 4.7

1.5 What this does to each stream's stated conclusion

  • S12's 27,585 B/h is the right unit and the right order for its own profile but is a replica measurement, not a production one. Corrected for the omitted emitters at that same profile it is 55,399 B/h. Its derived "114 service hours to the cap" is also the wrong unit for a kiosk: the journal accrues while the board idles too. Converted through S12's own 12-hour service the corrected figure is 4.7 days, not 9.5.
  • S2's 90.2 KiB/day has a sound shape — its heartbeat model (60 s while busy, 5 min while quiet) matches journal.dart:145 exactly — but it under-counts on both axes: 279 B per cycle where production writes 884 B, and 210 dish cycles/day, which is the low end. Its "34.1 days to the cap" should read 21.5 days at its own intensity, and 13 days at the reconciled one.
  • The severity of the rotation defect (S2-F16) does not change. 13 days rather than 34 is still a reachable-but-not-imminent MEDIUM under R13. What changes is the sentence S2 wrote around it: "a session must run 34 days to exceed the cap" is wrong by a factor of 2.6, and at S12's peak premise by a factor of 7.

2. The six claimed leaks — live or latent

The reachability question is settled by the navigation graph. Recorded grep proof/01_findings/S12_refute/grep_navigation_graph.txt, searching Navigator\.(push|pushReplacement|pushNamed)|MaterialPageRoute|routes:|HomeScreen\( across lib/:

lib/ui/home.dart:25:  const HomeScreen({super.key, required this.store});
lib/main.dart:55:      home: HomeScreen(store: store),

One construction site, one route, no full-screen push. lib/ui/modals.dart:13 uses showDialog, which pushes a route over HomeScreen without disposing it. CadenceApp is the root StatelessWidget of runApp and has no parent that can rebuild it. AndroidManifest.xml:31 declares configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode", so no ordinary configuration change recreates the activity; and an activity that is destroyed takes its FlutterEngine — and therefore the isolate that owns every leaked object — with it.

Therefore _HomeScreenState.dispose() has exactly one production trigger at 03a176e, and it is the teardown of the isolate the leak lives in. That does not make these non-findings; it makes them latent, and the distinction is the difference between "fix this before the pilot" and "fix this before anyone adds a second route".

Finding Code fact Live or latent Verdict
S12-F1 orphaned 150 ms ticker CONFIRMED, reproduced red (repro_s12_w7_boot_race.txt, EXIT_CODE=1, the binding names _HomeScreenState._boot (home.dart:154:21)) Latent REFUTED on severity. HIGH rests on "two ticks running concurrently … a battery that drains twice as fast". S12's own grep proves that cannot happen at this commit, and the isolate that would host the orphan is the one being destroyed. LOW today, HIGH the day a second route exists.
S12-F2 delayed announcement timers CONFIRMED, reproduced red (repro_s12_w0_ticker_live.txt, EXIT_CODE=1) Latent CONFIRMED. MEDIUM is right: the code genuinely has no cancel path, the fix is four lines, and the timer window is real.
S12-F3 Backstop._debounce arms after unmount CONFIRMED that Backstop has no disposal member Latent REFUTED on harm. See §2.1 — the alarm armed after teardown is correct, not phantom.
S12-F4 Journal.init() overwrites its timers CONFIRMED, reproduced (repro_s12_soak.txt, SOAK A5: timers created: 4 … STILL-LIVE periodic timers: 2) Latent REFUTED on severity. main() is the only caller and an activity recreation gives a fresh isolate; the only in-isolate second init() is a debug hot restart. LOW, not MEDIUM.
S12-F5 five AudioPlayers never released CONFIRMED, reproduced (repro_s12_w5_dispose_scope.txt: AudioPlayer create calls during boot: 5 / calls after unmount: []) Latent, and not an accumulation REFUTED on classification. SoundBox.init() is called once, from _boot(). Five players exist for the process lifetime and never grow. That is a missing dispose, not a leak; the resource table's "LEAK" and the totals line "6 are undisposed on at least one reachable path" overstate it.
S12-F6 native pendingSpeaks on a wedged utterance CONFIRMEDMainActivity.kt:35,147,160-162; AppDelegate.swift:28,173,182,188-189 LIVE — the only one CONFIRMED with a narrowing. MainActivity.kt:150 calls t.speak(text, TextToSpeech.QUEUE_FLUSH, params, id). QUEUE_FLUSH makes the next utterance interrupt the previous one, which fires onStop(id, interrupted)completeSpeak (:134) and clears 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". MEDIUM stands; the trigger description does not.

2.1 S12-F3 is backwards, and its applied fix makes the safety net worse

S12-F3 calls the post-teardown zonedSchedule "a phantom ring — exactly the outcome the file's own comment at :125-126 says it never risks". I measured what that alarm is actually armed for.

proof/01_findings/S12_refute/r7_backstop_dispose_PINNED.txt, pinned source, start Manouche, press −10, tear down 50 ms later:

=== S12R B1 — OS alarm instants across a debounce-window teardown ===
zonedSchedule calls before the −10 s press: 1
zonedSchedule calls after teardown+600 ms : 2
armed instants (epoch ms)                 : [1785841924981, 1785841914981]
second arm is -10000 ms relative to the first  (the −10 s press should move it by −10000 ms)
VERDICT: the OS safety net ends up holding the NEW (post −10 s) deadline

The second zonedSchedule lands at exactly the operator's new deadline for a timer that is still running, whose state has already been written to SharedPreferences. That is the safety net doing its job at the moment it matters most. alarm_backstop.dart:125-126 warns about arming an alarm for a stopped timer; this is not that case. And Backstop.init() calls _plugin.cancelAll() (:89) on the next launch, so nothing survives into a session that does not want it.

Now the same scenario with S12's fix_announce_timer_leak.patch applied (proof/01_findings/S12_refute/r6_backstop_dispose_WITH_s12fix.txt):

zonedSchedule calls before the −10 s press: 1
zonedSchedule calls after teardown+600 ms : 1
armed instants (epoch ms)                 : [1785841921000]
VERDICT: the OS safety net ends up holding the STALE (pre −10 s) deadline

The fix discards the re-arm. The tablet is left with an OS alarm ten seconds later than the deadline the cook asked for. Under R13 that is the BLOCKER class of failure — an alarm that does not ring when it should — traded for a duplicate notification that was never wrong.

The class already documents the correct policy for imminent teardown, at alarm_backstop.dart:240-242 inside onBackground():

// We may be killed any moment now → commit any debounced re-arm first, so
// no changed deadline sits in the 300ms window without its OS alarm.
_flushSchedules();

Backstop.dispose() must do the same thing before releasing: _flushSchedules(); _debounce?.cancel(); _debounce = null; _pending = null;. Recorded as S12R-F3.


3. The endurance claims

3.1 "1,033 widget builds per second, ~44.6 million per service, zero RepaintBoundary"

Every number reproduces on my copy. proof/01_findings/S12_refute/repro_s12_w1_idle_tick_cost.txt:

=== 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

RepaintBoundary absence verified independently, not taken from S4: proof/01_findings/S12_refute/grep_repaintboundary.txtgrep -rn "RepaintBoundary" lib/, EXIT_CODE=1, no output.

Then the question S12 did not ask: does the build reach layout or paint? proof/01_findings/S12_refute/r3_idle_tick_paint.txt runs the real booted _HomeScreenState with debugPrintMarkNeedsLayoutStacks and debugPrintMarkNeedsPaintStacks:

=== S12R W1B — what an IDLE 150 ms tick actually costs ===
widgets rebuilt by one idle tick        : 155
markNeedsLayout calls by one idle tick  : 16
markNeedsPaint  calls by one idle tick  : 2
400 idle ticks (build+layout+paint+setState): 717633 us total, 1794.1 us/tick
400 no-op pumps (nothing dirty)             : 11281 us total, 28.2 us/pump
net cost attributable to the rebuild      : 1765.9 us/tick

And the same measurement with S12's own proposed fix (a) applied as a mutation — if (mounted && engine.run.isNotEmpty) setState(() {}), patch at proof/01_findings/S12_refute/mutations/MUT-R3_skip_idle_rebuild.patch:

widgets rebuilt by one idle tick        : 0
markNeedsLayout calls by one idle tick  : 0
markNeedsPaint  calls by one idle tick  : 0
400 idle ticks: 21379 us total, 53.4 us/tick     net cost: 7.5 us/tick

Honest verdict. The counter-argument "Flutter rebuilds are cheap and a build is not a repaint" is itself only half right here: an idle tick does reach layout and paint, so a frame is genuinely produced 6.67 times a second on a board where nothing is changing. The guard removes 1,766 µs of work per tick, a 235× reduction on this host. That is a real, cheap, one-line win and the finding deserves to be in the report.

What is not supported is S12's severity and its consequence paragraph: "this rebuild rate is the app's floor power draw", "the difference between a board that is responsive at 21:00 and one that is thermally throttled". Nothing here is a power measurement. The 1,766 µs is debug-JIT Dart with asserts enabled and the flutter_test binding inside the loop, on Apple Silicon — not release AOT on an ARM tablet. And the dominant power term on a board held twelve hours behind WakelockPlus.enable() is the panel itself, which is by design. Severity HIGH → MEDIUM.

The exact artefact that would settle it is the one S12 already wrote and did not run: install the release build on the target tablet, adb shell dumpsys batterystats --reset, one hour foreground with an empty board unplugged, adb shell dumpsys batterystats --charged dev.sergemio.cadence, repeat with the guard applied, report the delta in mAh. Until that capture exists the claim is a rebuild count.

3.2 Burn-in: "nothing mitigates it"

The grep is CONFIRMED. proof/01_findings/S12_refute/grep_burnin_and_device.txt widens S12's search to include oled|amoled|lcd panel|panel type across lib/, android/, ios/ and README.md: EXIT_CODE=1, no output. Nothing in the app mitigates burn-in, and nothing in the app names a panel.

The finding is REFUTED on its premise, not on its grep. S12-F8 costs the risk as "a warranty complaint three to six months after the sale" and "the ghost image will be of our header" — an OLED-specific failure. The audit establishes the panel nowhere:

  • grep -rniE "lenovo|samsung|galaxy tab|amazon fire|oled|amoled|lcd|ips" over the repo returns only the French/English word "tablette"/"tablet" in UI strings and the unrelated token LCD ghost at lib/ui/tile.dart:1,484, which is a drawing style (the unlit 888 segments), not a panel fact.
  • No captured field journal exists anywhere in the repo or in Cadence_App_Audit/research/, so the APPAREIL <manufacturer> <model> line that lib/journal.dart:84 writes on every session has never been read into this audit.

A finding that assumes the wrong panel is a wrong finding, and this one has 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> (<brand>) · Android <release> · SDK <n> (lib/journal.dart:119-120). The precise test that flips the verdict: read that model number, look it up on the manufacturer's own product page, and record the display technology. OLED → S12-F8 stands at MEDIUM. LCD → the emitter-ageing mechanism does not apply and the finding drops to LOW as image-persistence only.

Two further problems with S12-F8 independent of the panel:

  • Its proposed fix (b) — "a full-screen black Opacity overlay dismissed by the first touch" — is a new end-user feature, and S12 asserts the opposite ("no new user-facing capability"). An idle screen-dim mode with a touch-to-wake gesture is a new interaction, and on a kitchen pass it is a safety-relevant one: a cook glancing over sees black. R6 violation.
  • Its proposed fix (a) — shifting the grid origin by ±3 px once a minute — moves every touch target on a board operated by people with wet hands. That is a change to an existing screen's behaviour, so it is arguably in scope, but it belongs to S4 (widget correctness, touch targets) not to S12, and it needs S4's sign-off before Phase 4 adopts it.

3.3 The soak methodology — it can go red, but not on the number it exists to produce

S12's mutation claim is true as far as it goes and I reproduced its shape: two mutations (Engine.stopTimer no longer dissolving a stopped clone; Journal._rotate a no-op) turn SOAK A1, A2 and A3 red. So the soak is not an inert shell.

But the soak's headline output is journal growth: 27585 bytes/hour, and no assertion defends it. A1's only journal assertion is expect(file.lengthSync(), greaterThan(0)) (s12_soak_test.dart:341). I proved this with a mutation that changes the number and nothing else — proof/01_findings/S12_refute/mutations/MUT-R1_journal_ts_drops_ms.patch, which drops the milliseconds from Journal._ts() (journal.dart:58), shortening every line by 4 bytes:

journal lines written by host=4707  journal file bytes=309498
journal growth: 25792 bytes/hour of service; 3 MB boot-rotation cap reached after 122.0 service hours
00:00 +6: All tests passed!
EXIT_CODE=0

The growth figure moved 6.5 % and the cap estimate moved 8 service hours, and all six soak tests stayed green. A number that no test can turn red is a print statement, not a measurement — which is precisely how the 2.0× emitter omission in §1 survived S12's own R8 gate. Recorded as S12R-F6.

The structural reason is worth stating plainly: s12_soak_test.dart does not import lib/ui/home.dart at all. Its SoakHost is a hand-written replica of _HomeScreenState, so no change to the production journal emitters can reach it.


4. Review of the applied fixes (fix_announce_timer_leak.patch)

Applied verbatim to my copy and re-run.

  • Red → green reproduces. proof/01_findings/S12_refute/r4_s12fix_w0_after.txts12_w0_ticker_live_test.dart + s12_w6 + s12_w2, 00:01 +3: All tests passed!, EXIT_CODE=0, against EXIT_CODE=1 on pinned source.
  • The app's own suite stays green. proof/01_findings/S12_refute/r5_s12fix_app_suite.txt00:03 +123: All tests passed!, EXIT_CODE=0.
Hunk Correct? Minimal? R6? Verdict
final Set<Timer> _announceTimers = {} + late final Timer t; t = Timer(delay, () { _announceTimers.remove(t); … }); _announceTimers.add(t); Yes. late final is assigned before any Timer callback can run (callbacks are asynchronous), so the self-removal is safe. Set<Timer> uses identity, which is what is wanted. Yes, 8 lines In scope — defect repair ADOPT
dispose() iterates _announceTimers, cancels, clears Yes. Timer.cancel() does not invoke the callback, so there is no concurrent-modification hazard during the loop. Yes In scope ADOPT
Backstop.dispose() cancelling _debounce and nulling _pending, called from _HomeScreenState.dispose() No — see §2.1. It discards a committed deadline change and leaves the OS safety net on a stale instant, contradicting the class's own policy at :240-242. Yes In scope DO NOT ADOPT AS WRITTEN. Adopt with _flushSchedules(); as the first statement of dispose().

Phase 4 should take hunks 1 and 2 unchanged and hunk 3 only with the flush added.


5. Findings S12 missed (R5)

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

  • Severity: MEDIUM
  • Location: lib/ui/home.dart:54-56 (declarations), :319, :361, :414, :415 (the only writes) (valid at 03a176e)
  • What is wrong: _flash, _justOn and _spawn are Map<String, int> keyed by tile id. Every step advance, every start and every batch spawn increments an entry. There is no removal or clear site anywhere in lib/. Clone ids are freshly minted per spawn — spawnClone builds CloneRef(id: uid(), …) at lib/engine/engine.dart:195, and uid() (:60-62) is two random base-36 draws — and a clone dissolves on stop (engine.dart:221 clones.removeAt(ci)). Every clone ever spawned therefore leaves two permanent entries, keyed by an id that no longer refers to anything, for the life of the isolate. Deleting a timer definition (deleteDef, :408-416) leaves its _flash/_justOn entries behind too. This is live growth: it does not need a dispose(), a second route, or a hot restart. It happens on the pass, during service. It directly contradicts S12's own headline — "Does anything grow without bound during a 12-hour service? Yes — one thing: the journal file."
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: it is the smallest of the growth terms in absolute bytes, but it is the only one that grows with operator activity rather than with time, and it grows fastest exactly during a busy service on a device with little memory to spare. More to the point for this audit: S12 declared the board structurally bounded and handed the coordinator a headline that says one thing grows. Two of the three maps grow with every batch a cook fires.
  • Proposed fix: 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 the fix: 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-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

  • Severity: LOW
  • Location: 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) (valid at 03a176e)
  • What is wrong: _boot() assigns Journal.snapshot a closure that reads engine.timers, engine.run, engine.clones and _foreground — it captures this, the whole _HomeScreenState, and through it the Engine, the SoundBox with its five AudioPlayers, the VoiceBox and the Backstop. Journal.snapshot is a static field. dispose() never nulls it, and the only site in the codebase that does is Journal.disableForTests() (journal.dart:246), annotated @visibleForTesting. Combined with S12-F4 — Journal._beat has no production cancel path either — the 60-second heartbeat keeps calling that closure after the screen is gone and keeps writing · battement timers=… actifs=… sonnent=… lines describing a board that no longer exists. S12's coverage manifest lists "Journal.snapshot closure retention" as checked in home.dart and reports no finding on it.
  • Evidence: 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;
  • Why it matters for a restaurant kitchen: the journal is the pilot's only forensic record, and this is the one path by which it can start lying — a heartbeat describing a dead board reads exactly like a live one. It is latent for the same reason S12-F1 through F5 are (the isolate goes with the screen), but unlike them it is a static reference, so it is the one that would also survive a future engine-caching or multi-window change.
  • Proposed fix: one line in dispose(), before super.dispose(): Journal.snapshot = null;.
  • How to prove the fix: 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.

S12R-F3 — S12's Backstop.dispose() leaves the OS safety net on a stale deadline

  • Severity: HIGH if Phase 4 adopts fix_announce_timer_leak.patch as written
  • Location: the patch's second hunk, against lib/alarm_backstop.dart:266; the policy it contradicts is at lib/alarm_backstop.dart:240-242 (valid at 03a176e)
  • What is wrong / Evidence / Fix: in full at §2.1 above, with the two recorded runs proof/01_findings/S12_refute/r7_backstop_dispose_PINNED.txt (2 arms, second at −10,000 ms — the correct new deadline) and proof/01_findings/S12_refute/r6_backstop_dispose_WITH_s12fix.txt (1 arm, stale deadline).
  • Why it matters for a restaurant kitchen: the backstop exists so that a timer rings when the app cannot. A backstop pinned to a deadline the cook has already moved rings ten seconds late for a dish where ten seconds was worth pressing a button over.
  • Proposed fix: void dispose() { _flushSchedules(); _debounce?.cancel(); _debounce = null; _pending = null; }.
  • How to prove the fix: 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.

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

  • Severity: MEDIUM (R12 proof integrity)
  • Location: proof/01_findings/S12/*.txt — all 14; findings/S12_lifecycle.md:542-543 states the cause
  • What is wrong: S12 ran every command with CADENCE_REPO=the app repository while working in a scratch working copy. run_and_record.sh honours CADENCE_REPO as its first resolution step, so the header describes the pristine original, not the tree the command actually ran against. Three of the recorded runs were made with the copy modified: mutation_soak_goes_red.txt (two mutations applied), w0_AFTER_fix_green.txt and baseline_suite_AFTER_fix.txt (the fix patch applied). All three read TREE_STATE: CLEAN. This is the same class of failure the S1 refuter found and the script's own comment names — "which is worse than no stamp, because it looks authoritative" — reached from the opposite direction: not rev-parse climbing out, but CADENCE_REPO pinned to the wrong tree.
  • Evidence: ``` $ 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 `` - **Why it matters:** a reader cannot distinguish S12's "red by design" runs from a run made under an unreverted mutation, which is the exact confusion the stamp exists to prevent. - **Proposed fix:** for the audit — re-record the three affected files withCADENCE_REPOunset and the copy initialised as its own git repository. Forrun_and_record.sh— whenCADENCE_REPOis set but does not contain$(pwd -P), emitREPO_MISMATCHin the header instead of a clean stamp. - **How to prove the fix:** re-runmutation_soak_goes_red.txtunder the same mutation from a copy that is its own repository and assert the header readsTREE_STATE: DIRTY`. Red now, green after.

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

  • Severity: MEDIUM (R8)
  • Location: proof/01_findings/S12/mutation_stoptimer_keeps_clone.patch, proof/01_findings/S12/mutation_soak_goes_red.txt, findings/S12_lifecycle.md:641-659
  • What is wrong: R8 requires (a) a whole-suite run captured with --reporter=json whose failing set is exactly the named test, (b) "failure" not a load-time "error", (c) a patch distinct per test, (d) git status --porcelain empty after revert, recorded.
  • (a) not met — the run is -r expanded; there is no JSON record.
  • (c) not met — one patch file carries two mutations and is used to justify three red tests. The Engine.stopTimer mutation alone accounts for both SOAK A1 and SOAK A2 going red; R8 forbids reusing one mutation across several tests.
  • (d) not met — the findings file asserts diff -rq lib <pinned> → identical in prose, but no recorded run of that check exists under proof/01_findings/S12/.
  • (b) is met: both A1 and A2 fail on expect, not on load.
  • Evidence: $ 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
  • Why it matters: the mutation gate is what separates a soak that measures from a soak that passes. S12's gate is real but under-specified, and §3.3 shows exactly what slipped through it.
  • Proposed fix: 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 the fix: 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

  • Severity: MEDIUM (R8)
  • Location: proof/01_findings/S12/tests/s12_soak_test.dart:321-341
  • What is wrong / Evidence: in full at §3.3. 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 and no test can turn red on it. Demonstrated with proof/01_findings/S12_refute/mutations/MUT-R1_journal_ts_drops_ms.patch — the figure moves to 25,792 B/h and the whole file still reports 00:00 +6: All tests passed! EXIT_CODE=0.
  • Why it matters: this is the mechanism by which a 2.0× under-count reached the coordinator with an R8 stamp on it.
  • Proposed fix: 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 the fix: apply MUT-R1_journal_ts_drops_ms.patch and show SOAK A1 goes red.

6. Coverage manifest — every file in S12's scope, and what I checked in it

File Lines What this refutation checked
lib/ui/home.dart 722 Read in full. Re-verified every file:line in S12-F1, F2, F7, F10 at 03a176e. Ticker creation at :154 behind four awaits and dispose()'s _ticker?.cancel() at :238 — reproduced red. _announceIfStill :275-281. All four AppLifecycleState branches. Every setState/await/mounted triple (:448, :456, :491 unguarded — confirmed). New: _flash/_justOn/_spawn at :54-56 traced to five write sites and zero removal sites (S12R-F1). New: Journal.snapshot closure at :93-103 traced to the static field and to the absence of any clear (S12R-F2). Journal emission audited against Backstop/VoiceBox/AlarmVolume for §1.
lib/main.dart 58 Read in full. Single HomeScreen(store: store) at :55 as MaterialApp.home — the basis of the live-vs-latent verdict. Journal.init single call site :25. Wakelock :28. Confirmed CadenceApp has no parent that can rebuild it.
lib/journal.dart 250 Read in full. _beat/_autoFlush at :106-107, only cancel site :241-242 inside @visibleForTesting — confirmed. _heartbeat()'s quiet-window logic at :145 measured against the real wall clock — the cause of both soaks' wrong idle floor (§1.3). _ts() at :55-59 used as the MUT-R1 mutation point. _flush fsync-per-log :163-180. _rotate :192-203. static String Function()? snapshot at :40. Per-line byte costs measured through the real Journal.
lib/alarm_backstop.dart 279 Read in full. _debounce :41, armed :143-148, released only at :145/:155 — confirmed no disposal member. sync()'s diff logic :122-149 and _flushSchedules :154-168 traced to establish what the post-teardown arm actually schedules. onBackground's _flushSchedules() policy comment :240-242 — the basis of S12R-F3. _schedule's journal line :197, _cancel's :228 — the two emitters both streams omitted. Driven live (plugin mocked) in r1_journal_reconcile.txt.
lib/audio/voice.dart 204 Read in full. _queue bound via staleMs :36/_dropStale :180-188. Both unstored re-drain timers :177, :201. The 12 s timeout :166-173 and the stopFor guard :193 that S12-F6 depends on. New: Journal.log(' parole', …) at :165 — one journal line per announcement, omitted by both streams.
lib/audio/audio.dart 116 Read in full. _newPlayer() :37-42, four pooled + one ring at :44-48, no dispose/release anywhere — confirmed, and confirmed init() has exactly one caller, which is what refutes the "leak" classification (S12-F5).
lib/audio/alarm_volume.dart 68 Read the state and journal path. New: Journal.log('volume', …) at :62 fires on every ring rising edge — omitted by both streams. Confirmed no timers, listeners or growing state.
lib/engine/engine.dart 432 Read the lifecycle-relevant regions in full: uid() :60-62 (fresh id per clone — the key to S12R-F1), spawnClone :191-200, removeClonesOf :203-213, stopTimer's clone dissolution :218-223, deleteDef :408-416, _fireAlarm/_alarmRepeat :270-300, tick() :302-340, the repeat cadence constants :53-55. Driven for 288,001 ticks × 8 profiles.
lib/engine/store.dart 354 Read the seed block :300-350 (the dish set every soak uses) and the save paths. Payload growth re-confirmed flat via repro_s12_soak.txt.
lib/engine/models.dart 160 Read for disposable resources — none. Absolute endsAt design confirmed as the reason state survives a reboot.
lib/diagnostics.dart 54 Read in full. Ring buffer cap 50, critical ValueNotifier static-by-design, Diag.fail's own journal line :37 (one per failure — not a per-cycle emitter, so outside §1's model).
lib/ui/tile.dart 819 Read the resource and repaint regions: all six AnimationControllers created :75-85 and disposed :147-152 — confirmed OK. AnimatedBuilder/Listenable.merge at :161-162. New: _PiePainter.shouldRepaint at :785 and _DashedOutline.shouldRepaint at :817 — read to answer whether an idle rebuild repaints; answered empirically instead in r3_idle_tick_paint.txt.
lib/ui/modals.dart 746 Checked the route mechanics only (showDialog at :13, the six Navigator.pop sites) — enough to establish that no route push disposes HomeScreen. Controller disposal is S12's finding-free area and I did not re-derive it.
lib/ui/header.dart, grid_layout.dart, theme.dart, logo.dart, i18n.dart 215 / 109 / 82 / 18 / 167 Checked for resources (none) and for the burn-in grep surface. i18n.dart:148-164 read to derive the announcement phrase used in the byte-cost table.
android/app/src/main/AndroidManifest.xml 79 Read in full. configChanges at :31 — the basis of the "no ordinary config change recreates the activity" step in the live-vs-latent verdict. MAIN/LAUNCHER-only intent filter confirming S12-F9. Both plugin receivers :45-56.
android/.../MainActivity.kt 176 Read in full. pendingSpeaks :35, speak :143-156including TextToSpeech.QUEUE_FLUSH at :150, which narrows S12-F6's triggercompleteSpeak :160-162, completeAllSpeaks :164-169, onDestroy :171-175.
ios/Runner/AppDelegate.swift 207 Resource scan. synth :23, pendingSpeaks keyed by ObjectIdentifier :28, :173, removal :182, :188-189 — same exposure as Android, confirmed.
proof/01_findings/S12/* 20 txt + 11 dart + 2 patches Every proof file's header read (S12R-F4). s12_soak_test.dart read in full — the assertion audit behind S12R-F6. Both patches read line by line. w1, w5, w6, w7, w0 and the full soak re-run on an independent copy.

Not re-derived: research/00_code_map.md (used as directed, not rebuilt); S2's non-journal findings; S4's widget-correctness scope; S1's engine-correctness scope.


7. Artefacts produced

Path under proof/01_findings/S12_refute/ Contents
r1_journal_reconcile.txt Journal growth, home-only vs full production emission, plus the five-intensity sweep. EXIT_CODE=0
r2_line_cost.txt Measured bytes per production journal line shape; derived per-cycle costs for both prior models
r3_idle_tick_paint.txt Idle tick: 155 builds, 16 layout marks, 2 paint marks, 1,766 µs net
r4_s12fix_w0_after.txt, r5_s12fix_app_suite.txt S12's fix patch applied on my copy — +3 green, and the app's own +123 green
r6_backstop_dispose_WITH_s12fix.txt, r7_backstop_dispose_PINNED.txt The armed OS-alarm instants with and without S12's Backstop.dispose() — the S12R-F3 evidence
repro_s12_w1…w7, repro_s12_soak.txt Independent reproduction of every S12 measurement
mutations/MUT-R1_journal_ts_drops_ms.{patch,txt,_json.txt} Soak stays green while the growth figure moves — S12R-F6
mutations/MUT-R2_backstop_drops_posee_line.{patch,_json.txt} R8-complete: failing set exactly R1, "failure", distinct patch, clean revert
mutations/MUT-R3_skip_idle_rebuild.{patch,_json.txt} S12-F7's own fix as a mutation: builds/layout/paint all fall to 0, cost 1,766 → 7.5 µs
grep_*.txt (7 files) Journal call sites; the three trigger maps and the absence of removal sites; Journal.snapshot; RepaintBoundary; burn-in and panel tokens; the navigation graph
tests/ (4 .dart files) Every test written for this refutation

Stream S13: finding and refutation

S13 — Data inventory and portabilityfindings/S13_data.md · raw .md

S13 — Data inventory and portability

Stream: S13 · Subject: the app repository @ 03a176e72ef0075eec86b8915cbe6e93042a3b9d (v0.4.12+18) Evidence root: proof/01_findings/S13/ Official captures: proof/03_market/captures/ Mode: read-only (R10). No file under cadence-app was created, edited, or deleted; no branch was switched; no git command that writes was run.

Method note on the probes. Generating a real exported payload requires running code, and R10 forbids adding a test file to the subject. Probes ran against a byte-identical copy at <scratch>/cadence-probe with only test/zz_probe_*.dart added; identity is proved in proof/01_findings/S8/probe_tree_identical.txt.

This inventory is the input to Phase 2's Google Play Data Safety form and Apple privacy nutrition labels. Every row was established from code or from the shipped binary, not from the README.

Scope boundary vs the CHECKLIST partition. CHECKLIST.md:66 makes S13 a cross-cutting lens owning no file, secondary on lib/journal.dart for the export path. The coordinator's brief widened this stream to "every piece of data the app reads, writes, or transmits", including lib/engine/store.dart, lib/diagnostics.dart and all ten dependencies, which is what §1-§4 cover. Findings in files another stream owns are stated only along the data-inventory dimension: lib/engine/store.dart (S2) for what each key holds and how long, not for persistence correctness; AndroidManifest.xml (S9) for backup and network permissions, not signing; pubspec.yaml (S6) for what each dependency touches, not for whether it is needed.


Summary of the three answers the coordinator asked for

Question Answer
Does the app make any network request? No. Zero network code in lib/, and the shipped release APK carries no android.permission.INTERNET, so on Android the operating system makes a network request impossible. Proof method in §3.
What does device_info_plus read, and where does it go? On Android the plugin reads every android.os.Build and Build.VERSION field plus Settings.Global.DEVICE_NAME (the owner-assigned device name); on iOS it reads the full UIDevice set including identifierForVendor. Cadence keeps six of those on Android and four on iOS, and discards the rest — including both the Android device name and the iOS vendor identifier. The six go into the journal file, which the operator can export and email. Detail and call sites in §4.
Stored items that are personal data under the GDPR 4 of the 14 stored items carry personal data: the timer definitions, their .corrupt siblings, the journal file, and every exported copy of it. All four are personal data because the operator types free text into the timer name and announcement fields and nothing constrains it. Table in §1.

1. Every data item the app stores

Enumerated by running a full session and dumping the resulting store, not by reading the key constants (proof/01_findings/S13/probe_data.txt, PROBE-A and PROBE-B).

The session created a timer named Frites Jean-Marc with the announcement Table 12 pour Mme Dupont — deliberately, to show what happens to operator free text.

SHAREDPREFS_KEY_COUNT=11
PREFKEY|cadence-clones-v1|String|{not json
PREFKEY|cadence-clones-v1.corrupt|String|{not json
PREFKEY|cadence-journal-beat|int|1785838162546
PREFKEY|cadence-journal-clean|bool|false
PREFKEY|cadence-lang|String|fr
PREFKEY|cadence-phrase-repair-v1|bool|true
PREFKEY|cadence-run-v1|String|{"qrb5vtjdqedr":{"status":"running","endsAt":…,"armedAt":…,"voiceGap":7000}}
PREFKEY|cadence-seeded-v1|bool|true
PREFKEY|cadence-timers-v1|String|[{"id":"qrb5vtjdqedr","name":"Frites Jean-Marc","durationSec":90,"sound":"Bell","phrase":"Table 12 pour Mme Dupont"}, …]
PREFKEY|cadence-vol|double|0.6
PREFKEY|cadence-zone-sound-v1|bool|true
FILE|<appdir>/cadence-journal.txt|185
# Item Where stored (exact key or path) What it contains Personal data under the GDPR? Retention Can the operator delete it?
1 Timer definitions SharedPreferences cadence-timers-v1 (lib/engine/store.dart:15, written :150) JSON array: opaque 12-char id, name (operator free text), durationSec, sound, phrase (operator free text), optional steps[] with free-text name Yes, conditionally. name and phrase are unconstrained text the operator types. In the probe: "name":"Frites Jean-Marc", "phrase":"Table 12 pour Mme Dupont". Nothing validates or strips a person's name Until uninstall, or until each timer is deleted individually Yes, per timer, via the 🗑 button (lib/ui/modals.dart:348lib/ui/home.dart:432). No bulk wipe
2 Run state cadence-run-v1 (:16, written :152) Per-timer status, endsAt, remainingMs, rangAt, armedAt, driftMs, voiceGap, nextVoiceAt — absolute epoch milliseconds No. Machine timestamps, no identity Until the timer is stopped; rewritten on every mutation Yes, implicitly — stopping a timer removes its entry (lib/engine/engine.dart:215-226)
3 Batch clones cadence-clones-v1 (:20, written :154) id, parentId, batchNo No Until the parent is stopped Yes, implicitly
4 Chrome language cadence-lang (:23, written :163) 'fr' or 'en' No Until uninstall No — it can only be changed, not cleared
5 Alarm volume cadence-vol (:24, written :171) Double in [0.15, 1.0] No Until uninstall No — only changed
6 Seed-done flag cadence-seeded-v1 (:21, written :308,351) true No Until uninstall No
7 Phrase-repair-done flag cadence-phrase-repair-v1 (:22, written :229) true No Until uninstall No
8 Zone-sound-migration flag cadence-zone-sound-v1 (:19, written :282) true No Until uninstall No
9 Legacy zones (pre-v0.4.11) cadence-zones-v1 (:18) Legacy JSON No Deleted by the one-shot migration at :283 n/a — removed automatically
10 Corrupt-value siblings '<key>.corrupt', dynamic, one per corrupted key (:122-123) A verbatim copy of whatever was in the original key at the moment it failed to parse — so a corrupt cadence-timers-v1 produces a .corrupt sibling holding every timer name and announcement phrase Yes, conditionally — same content class as item 1 Forever. :122 writes it only if absent, and nothing anywhere deletes it No. There is no UI, no migration, and no expiry. Grep for .corrupt in lib/ returns only store.dart:122,123
11 Journal heartbeat stamp cadence-journal-beat (lib/journal.dart:23, written :99,152,174) Epoch milliseconds of the last known-alive moment No Until uninstall No
12 Journal clean-exit marker cadence-journal-clean (lib/journal.dart:24, written :96,189) Bool No Until uninstall No
13 The flight recorder File <app documents>/cadence-journal.txt (lib/journal.dart:69-70). On Android that directory is Context.getDir("flutter", MODE_PRIVATE) — internal storage — per path_provider_android-2.3.1/lib/src/path_provider_android_real.dart:36-46 A timestamped, append-only transcript of the whole service: device description, app version, every timer start/stop/pause/alarm/step with the timer's operator-typed name, the exact spoken phrase (lib/audio/voice.dart:165), created/modified/deleted timer names (lib/ui/home.dart:432,451), settings changes, and every failure Yes. Contains items 1's free text plus a device description, correlated to a timeline Rotated at 3 MB down to the last 1 MB (lib/journal.dart:25-26,192-203); otherwise until uninstall No. There is no clear-log control anywhere in the app
14 Exported journal copies <temp dir>/cadence-log-<device>-<date>-<HH>h<mm>.txt (lib/journal.dart:221-226) and <external files dir>/<same name> (:229-230) A full copy of item 13 Yes, same content The temp copy is in cacheDir and may be reclaimed by Android; the external copy is never deleted by the app No in-app path. The external copy is reachable by file manager or USB

Count of stored items that are personal data under the GDPR: 4 of 14 — items 1, 10, 13 and 14. Items 1, 13 and 14 are always present; item 10 materialises only after a corrupt read of the timer key. The GDPR basis is that all four carry free-text fields the operator controls and a device description, which together identify a natural person when the operator types one in — and the probe shows the app does nothing to prevent that.


2. Findings

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

  • Severity: HIGH
  • Location: lib/journal.dart:205-237 (export) and lib/ui/modals.dart:696-717 (share) (valid at 03a176e)
  • What is wrong: Settings → 📤 Envoyer le journal copies the whole flight recorder to a temporary file and hands it to the OS share sheet with a pre-filled subject and body. Every timer name and every spoken announcement the operator typed is in it verbatim, alongside the device description and a minute-by-minute timeline of the service. Nothing is redacted, truncated, or summarised, and the operator gets no preview of what is being sent.
  • Evidence: 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:

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). - Why it matters for a restaurant kitchen: a cook uses a timer name as a note. Table 12 Mme Dupont, allergie arachide table 4, commande Legrand are exactly the kind of thing that gets typed into a free field during service, and it all ends up in an email attachment sent to a third party for diagnostics. For a restaurant customer that is a diner's name leaving the premises. Commercially this is the difference between "Cadence stores nothing about your customers" and a question you cannot answer in a sales meeting. - Proposed fix: 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 the fix: 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 — Android auto-backup is on by default and copies the journal and every preference into the operator's Google account

  • Severity: HIGH
  • Location: android/app/src/main/AndroidManifest.xml:20-23 (the <application> element) (valid at 03a176e)
  • What is wrong: the manifest declares no android:allowBackup, no android:dataExtractionRules and no android:fullBackupContent. The shipped APK's binary manifest confirms none of those attributes is present at all. Android's documented default therefore applies.
  • Evidence: 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. - Why it matters for a restaurant kitchen: the restaurant did not choose this and cannot see it. A diner's name typed into a timer name during Friday service is in a Google backup by Saturday, and a second declaration follows: Google Play's Data Safety form asks whether data is transferred, and a cloud backup the app enables by default is a fact the form has to reflect. It is also the wrong answer to a restaurant asking "does anything leave the tablet?", because the honest answer today is "yes, silently, to Google". - Proposed fix: 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. - How to prove the fix: 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".


S13-F3 — The app ships no PrivacyInfo.xcprivacy, and its own code calls required-reason APIs

  • Severity: BLOCKER (prevents store submission)
  • Location: absent from the whole tree; the calling code is lib/journal.dart:69-72, :170-171, :194-199, :226, :230 (valid at 03a176e)
  • What is wrong: there is no privacy manifest anywhere in the repository. find . -name 'PrivacyInfo.xcprivacy' -not -path './build/*' returns nothing (proof/01_findings/S13/device_info_reads.txt, section 7). Cadence's own Dart code reaches NSFileManager file-timestamp and disk-space APIs through dart:iof.exists(), f.length(), f.copy(), f.writeAsString() at the lines above — which Apple classifies as required-reason API. Two of the iOS plugin pods also ship no manifest of their own: path_provider_foundation-2.6.0 and audioplayers_darwin-6.5.0 (proof/01_findings/S13/… and the per-package count below).
  • Evidence: 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 May 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
- Why it matters for a restaurant kitchen: it does not — it matters for whether the product exists
on iOS at all. This is a hard submission gate, not a quality issue, which is why it is a BLOCKER
under R13. It is also currently invisible: the baseline records no Xcode on this machine
(AGENT_RULES.md, Toolchain row) and research/01_prior_work.md L7 records that
AppDelegate.swift has «JAMAIS COMPILE (pas de Mac)», so nothing has ever surfaced it.
- Proposed fix: 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.
- How to prove the fix: 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.

S13-F4 — There is no export or import of timer configuration, so a replaced tablet loses the kitchen

  • Severity: HIGH
  • Location: absent; the only export in lib/ is lib/journal.dart:207 (valid at 03a176e)
  • What is wrong: confirmed at 03a176e, as prior work asked. grep -rniE 'export|backup|restore' over lib/ returns exactly one export function, Journal.exportCopy(), which exports the flight recorder and nothing else. There is no way to read cadence-timers-v1 out of one tablet and into another, and no import path of any kind.
  • Evidence: 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} ] }
]
  • Why it matters for a restaurant kitchen: two concrete consequences, both commercial. A replaced tablet starts from zero. Kitchen tablets are dropped, splashed, and stolen. When one is replaced, someone must re-enter every dish, every duration, every chained step and every announcement by hand on a touchscreen, from memory, and the app will meanwhile have seeded the pilot kitchen's own menu over the top (S8-F3). With Android auto-backup currently on (S13-F2) a same-Google-account restore does carry the config across — which means the product's only configuration-recovery mechanism today is an undeclared Google backup nobody chose, and turning that off to fix S13-F2 removes it. A second site cannot be rolled out. Selling a chain its second restaurant means either re-typing the whole board or shipping a pre-configured tablet. There is no "copy this kitchen" action, which is the single most obvious multi-site feature and the one a chain will ask for in the first meeting.
  • Proposed fix: 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 the fix: 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).

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

  • Severity: MEDIUM
  • Location: lib/journal.dart:228-231 (valid at 03a176e)
  • What is wrong: every export writes a second copy outside the app's private storage:
// best-effort USB-reachable copy; never blocks the share
try {
  final ext = await getExternalStorageDirectory();
  if (ext != null) await _file!.copy('${ext.path}/$name');
} catch (_) {}

getExternalStorageDirectory() maps to Context.getExternalFilesDir(null) (path_provider_android-2.3.1/lib/src/path_provider_android_real.dart:57-67). The filename carries the date and time (:221-223), so each export creates a new file. Nothing in lib/ deletes any of them, and no rotation applies — _rotate (:192-203) only ever touches _file, the primary journal. - Evidence: 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. - Why it matters for a restaurant kitchen: the tablet accumulates one full transcript per export, each containing whatever operator free text was live at the time (S13-F1), in a location any file manager on the device can read and any person with the USB cable can copy. A pilot kitchen exporting daily for a month leaves 30 copies. The intent — USB retrieval for the pilot campaign — is sound; the absence of any cleanup is the defect. - Proposed fix: 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. - How to prove the fix: 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).


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

  • Severity: MEDIUM
  • Location: lib/engine/store.dart:119-128 (valid at 03a176e)
  • What is wrong: _preserveCorrupt copies the raw unparseable value into a '<key>.corrupt' sibling. This is a good decision — research/01_prior_work.md records it as the A1-4 fix, and losing a kitchen's data silently would be worse. But nothing ever removes the sibling. grep for .corrupt in lib/ returns only store.dart:122 and :123, both inside the write. When the corrupted key is cadence-timers-v1, the sibling holds every timer name and announcement phrase.
  • Evidence: 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):

if (_readString('$key.corrupt') == null) {
  _guard('$key.corrupt', prefs.setString('$key.corrupt', raw));
}
  • Why it matters for a restaurant kitchen: for the kitchen, nothing visible. For the privacy declaration it matters, because it is a copy of personal data with unbounded retention and no deletion path — which is precisely what a GDPR retention answer has to state. It is also a copy of a broken value, so it survives the very repair that fixed the live one.
  • Proposed fix: 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.
  • How to prove the fix: 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).

3. Network activity — definitive verdict and proof method

Verdict: the app makes no network request. On Android the operating system makes one impossible.

This claim is load-bearing for the store listing and for selling to restaurants, so here is the exact method by which it was proved, in four independent layers. Any one of them alone would be assumption; together they are proof.

Layer 1 — no network code in lib/. A single grep for every way Dart can reach a network, across all 4,853 lines (proof/01_findings/S13/network_grep_lib.txt):

grep -rnE "package:http|HttpClient|HttpRequest|InternetAddress|WebSocket|Socket\(|
           RawDatagramSocket|package:dio|package:web_socket|XMLHttpRequest|
           Uri\.parse|Uri\.https|Uri\.http" lib/
grep exit=1  (1 = no match anywhere in lib/)

dart:io is imported in exactly one file, lib/journal.dart:16, and the only dart:io symbols it uses are File, FileMode, Directory and Platform — file I/O and platform detection, no socket.

Layer 2 — the shipped binary cannot open a socket. The permission list read out of the release APK's own binary manifest (proof/01_findings/S13/apk_permissions.txt, APK SHA-256 f11a484d821ed4ab11121ea01f7291841dad320def22cbf9b8f9f61c77e7ca9e):

   android.permission.DUMP
   android.permission.MODIFY_AUDIO_SETTINGS
   android.permission.POST_NOTIFICATIONS
   android.permission.RECEIVE_BOOT_COMPLETED
   android.permission.SCHEDULE_EXACT_ALARM
   android.permission.USE_EXACT_ALARM
   android.permission.USE_FULL_SCREEN_INTENT
   android.permission.VIBRATE
   android.permission.WAKE_LOCK
INTERNET_PRESENT = False
ACCESS_NETWORK_STATE_PRESENT = False

Without android.permission.INTERNET, Android denies every socket the process attempts, whatever the Dart code does. (android.permission.DUMP is not the app's — it is the androidx.profileinstaller receiver's android:permission guard, visible in the merged manifest at the ProfileInstallReceiver element.) This is the strongest layer, and it is the one that would still hold if a future dependency added network code.

Layer 3 — the transitive dependency graph, examined rather than assumed. package:http 1.6.0 is in the resolved graph. Not declaring that would be the easy error. It is pulled in three times, and each import site is unreachable from this app (proof/01_findings/S13/transitive_http_reachability.txt):

Package importing http File Why it cannot run here
audioplayers 6.8.1 lib/src/audio_cache.dart:8, called at :94 The call is inside if (kIsWeb) { … await http.get(uri); … } at :90-96. Android and iOS take the rootBundle branch at :98-104. The app only ever constructs AssetSource (lib/audio/audio.dart:73) — grep for UrlSource, DeviceFileSource, BytesSource, AudioCache in lib/ returns nothing
timezone 0.11.1 lib/browser.dart:16 (browser_client) and lib/standalone.dart:41 (HttpClient()) The app imports neither. It imports timezone/data/latest.dart and timezone/timezone.dart (lib/alarm_backstop.dart:20-21) — the embedded database, no fetch
package_info_plus 10.2.1 (via wakelock_plus) lib/src/package_info_plus_web.dart Web implementation only; not compiled into an Android or iOS binary

share_plus, device_info_plus, shared_preferences, path_provider, vibration, flutter_local_notifications contain no package:http, HttpClient, InternetAddress or WebSocket in their lib/ at all.

Layer 4 — no analytics or crash-reporting SDK. Grepped pubspec.yaml and pubspec.lock — the lock file matters, because a transitive analytics SDK would appear only there — for firebase, crashlytics, sentry, analytics, amplitude, mixpanel, posthog, bugsnag, appcenter, datadog, google_mobile_ads, facebook. Exit code 1: absent (proof/01_findings/S13/network_grep_lib.txt, section 4). The app's only telemetry is Diag (50 entries in RAM, lib/diagnostics.dart:21) and the on-device journal, neither of which transmits.

The one qualification, stated precisely. Layer 2 is Android-only. iOS has no equivalent permission, so on an iOS build the guarantee rests on layers 1, 3 and 4 — code that does not call the network and dependencies whose network paths are unreachable. That is a strong claim but a weaker one than Android's, and it is worth knowing which is which before writing it into a store listing.

What the listing may therefore claim: Cadence works entirely offline. It sends nothing anywhere. It contains no analytics and no crash reporting. All three are true today. What it may not claim: that nothing leaves the device — the operator can export the journal themselves (S13-F1), and Android auto-backup is currently uploading the data silently (S13-F2).


4. device_info_plus — exactly what it reads and where it goes

This was flagged as the highest-risk item in the inventory. Resolved precisely, with call sites.

Who calls it: one place. grep -rn "DeviceInfoPlugin\|androidInfo\|iosInfo" lib/ test/ returns three lines, all inside Journal._describeDevice (proof/01_findings/S13/device_info_reads.txt, section 1). Verbatim, lib/journal.dart:114-130:

static Future<String> _describeDevice() async {
  try {
    final info = DeviceInfoPlugin();
    if (Platform.isAndroid) {
      final a = await info.androidInfo;
      return '${a.manufacturer} ${a.model} (${a.brand}) · Android '
          '${a.version.release} · SDK ${a.version.sdkInt}'
          '${a.isPhysicalDevice ? '' : ' · EMULATEUR'}';
    }
    if (Platform.isIOS) {
      final i = await info.iosInfo;
      return '${i.name} ${i.model} · iOS ${i.systemVersion}'
          '${i.isPhysicalDevice ? '' : ' · SIMULATEUR'}';
    }
  } catch (_) {}
  return Platform.operatingSystem;
}

What the plugin reads from the OS, versus what Cadence keeps. The distinction matters for the declarations: reading a value into memory is not collection, storing and exporting it is.

Platform What the plugin reads (native) What Cadence keeps
AndroidMethodCallHandlerImpl.kt:30-75 Build.BOARD, BOOTLOADER, BRAND, DEVICE, DISPLAY, FINGERPRINT, HARDWARE, HOST, ID, MANUFACTURER, MODEL, PRODUCT, TAGS, TYPE, the three supported-ABI lists, VERSION.BASE_OS, CODENAME, INCREMENTAL, PREVIEW_SDK_INT, RELEASE, SDK_INT, SECURITY_PATCH, plus Settings.Global.DEVICE_NAME (:44) — the name the tablet's owner gave the device 6 fields: manufacturer, model, brand, version.release, version.sdkInt, isPhysicalDevice. DEVICE_NAME is read but discarded. No ANDROID_ID, no SERIAL — grep confirms the plugin reads neither
iOSFPPDeviceInfoPlusPlugin.m:59-65 [device name] (:59), systemName, systemVersion (:61), model, localizedModel, utsname, disk and RAM figures, and [[device identifierForVendor] UUIDString] (:65) 4 fields: name, model, systemVersion, isPhysicalDevice. identifierForVendor is read but discarded

Where the six fields go. Into Journal.device (lib/journal.dart:36,75), and from there into exactly three places (proof/01_findings/S13/device_info_reads.txt, section 3):

  1. the APPAREIL <device> line of every journal session header (lib/journal.dart:84) — so it is in the exported file, visible in PROBE-C as APPAREIL macos;
  2. the exported filename slug (lib/journal.dart:215-223), e.g. cadence-log-macos-2026-08-04-12h09.txt;
  3. the share-sheet subject and body (lib/ui/modals.dart:707,710) and the Settings hint text (lib/ui/modals.dart:683), where it is shown to the operator.

The two facts that change a declaration:

  • identifierForVendor is a device identifier and the plugin reads it on every iosInfo call. Cadence never stores it, never writes it to the journal, and never transmits it (there is no transmission — §3). It exists only in the returned object's memory for the life of one call. That is not "collection" under either store's definition, but it is the kind of thing a reviewer looks for, and device_info_plus declares nothing in its own PrivacyInfo.xcprivacy (empty NSPrivacyAccessedAPITypes, empty NSPrivacyCollectedDataTypes, NSPrivacyTracking false — quoted verbatim in proof/01_findings/S13/device_info_reads.txt, section 6). If Phase 2 wants the strongest possible answer, the fix is to stop calling info.iosInfo and read the four wanted fields another way — but as shipped, the honest declaration is no device identifier collected.
  • On iOS, IosDeviceInfo.name is UIDevice.current.name, and Cadence does write it into the journal. Where the OS returns the owner-assigned name — "iPad de Serge" — that is a person's name in an exported, emailable file. The exact missing artifact: no iOS build has ever been made (AGENT_RULES.md Toolchain: no Xcode; research/01_prior_work.md L7: «JAMAIS COMPILE (pas de Mac)»), so the value this returns on a current iOS version has never been observed for this app. The precise test that settles it: build the Runner target on a Mac, launch on a physical iPad, and read the APPAREIL line of the first journal session header. If it contains the owner-assigned name, item 13 of §1 gains a second personal-data field and lib/journal.dart:125 must drop i.name in favour of i.modelName. Until that build exists, treat i.name as personal data.

5. Portability, backup and restore — the consolidated picture

Question Answer today
Can the operator export their timer configuration? No (S13-F4). The only export is the flight recorder.
Can they import one? No. No import path of any kind exists in lib/.
Does the configuration survive a tablet replacement? Only by accident, through Android auto-backup into the same Google account (S13-F2) — an undeclared mechanism nobody chose, which the fix for S13-F2 would remove unless replaced with an explicit dataExtractionRules.
Does it survive on iOS? Unknown and unbuilt. iCloud backup would carry NSUserDefaults and the Documents directory by default, but no iOS build exists to verify. Same missing artifact as §4.
Can a chain roll out a second site from a first? No. Every dish must be re-entered by hand, on a touchscreen, while the app has seeded a different kitchen's menu underneath (S8-F3).
Can the operator delete their data? Partially. Timers: yes, one at a time. Journal: no. Corrupt siblings: no. Settings: no, only changed. Full deletion requires uninstalling the app.

6. What I checked and found nothing

  • Network. Four independent layers, §3. No request, no capability on Android, no reachable network path in any dependency, no analytics or crash SDK. This is a clean result and it is the most commercially valuable one in the stream.
  • Advertising identifiers. No google_mobile_ads, no AdMob, no advertisingId read anywhere. Grepped pubspec.lock as well as pubspec.yaml.
  • Location. No location permission in the manifest, no geolocator/location dependency, no call site. Nothing to report.
  • Camera, microphone, contacts, calendar. None of the corresponding permissions is in the manifest and no corresponding dependency exists. The nine permissions in §3 layer 2 are the complete list, and every one of them is an alarm, vibration, audio-stream or wake-lock permission.
  • Accounts and sign-in. No authentication of any kind. No user account exists as a concept in lib/.
  • android.permission.DUMP. Traced: it belongs to the androidx.profileinstaller ProfileInstallReceiver merged in by the Android Gradle plugin, as an android:permission guard on a receiver, not a permission the app requests for itself. Not a data item.
  • Databases. No SQLite, no sqflite, no Hive, no Isar. All persistence is the 13 items in §1.

7. Coverage manifest

File / artifact Lines What I checked
lib/engine/store.dart 354 Read in full. Enumerated all nine static key constants and the dynamic .corrupt family; traced every write and read site; ran a full session and dumped the resulting store (PROBE-A); forced the corrupt path and confirmed the sibling persists; dumped the timer JSON verbatim (PROBE-E).
lib/journal.dart 250 Read in full. Traced init_describeDevicedevice; every _buf.add in the session header; log, _flush, _rotate, markCleanExit, exportCopy; both file destinations and both preference keys. Generated a real export payload (PROBE-C) and a real post-export directory listing (PROBE-D).
lib/diagnostics.dart 54 Read in full. Confirmed Diag.log is capped at 50 in RAM (:21,30), _warned is a process-lifetime set (:23), and the only persistence is the Journal.log call at :37,44. No file, no transmission.
lib/ui/modals.dart 746 Read :590-746. The _sendJournal share call (:698-717) quoted verbatim; confirmed it is the only SharePlus call site in lib/; the Settings hint showing Journal.device at :683.
lib/ui/home.dart 722 Grepped all 30 Journal.log sites in this file (43 across lib/) and read the ones carrying operator data — :432 (deleted timer name), :451-454 (created/modified name, steps, tone, announcement), :476,485 (settings changes).
lib/audio/voice.dart 204 :165 logs the exact spoken text into the journal, and :185 logs dropped announcements — both operator free text. Confirmed the cadence/tts channel carries text out to the platform text-to-speech engine and nothing back that is stored.
lib/alarm_backstop.dart 279 Confirmed notification payloads carry the timer name (:186,259) and go to the OS notification service, not a network. No SharedPreferences, no file.
lib/main.dart 58 Confirmed the boot order: Store.open() then Journal.init(store.prefs, kAppVersion). No other data initialisation.
lib/audio/audio.dart 116 Confirmed the only source constructed is AssetSource (:73) — the bundled WAVs. No UrlSource, no AudioCache use, so the http path in audioplayers is unreachable.
lib/audio/alarm_volume.dart · lib/engine/engine.dart · lib/engine/models.dart · lib/i18n.dart · lib/ui/theme.dart · lib/ui/grid_layout.dart · lib/ui/header.dart · lib/ui/tile.dart · lib/ui/logo.dart 68 · 432 · 160 · 167 · 82 · 109 · 215 · 819 · 18 Checked each for data storage, file I/O and network: none has any. models.dart defines the JSON shapes of items 1-3 and was read for that reason; the rest hold no persistence at all. Confirmed by the File( grep returning only journal.dart:70,225 plus the XFile wrapper at modals.dart:706.
android/app/src/main/AndroidManifest.xml 74 Read in full. All nine declared permissions; the share_plus FileProvider and SharePlusPendingIntent receiver; the two flutter_local_notifications receivers; absence of allowBackup/dataExtractionRules/fullBackupContent.
build/app/outputs/flutter-apk/app-release.apk 53,629,091 bytes Extracted and parsed the binary AndroidManifest.xml; enumerated permissions in both UTF-8 and UTF-16 string-pool encodings; checked for allowBackup, usesCleartextTraffic, networkSecurityConfig, dataExtractionRules. SHA-256 recorded in the proof file.
build/app/intermediates/merged_manifests/release/…/AndroidManifest.xml 178 Read in full — the merged source of the APK manifest, confirming targetSdkVersion="36", the <application> attribute list, and every merged-in plugin component.
android/app/build.gradle.kts applicationId / namespace dev.sergemio.cadence; confirmed no manifestPlaceholders affecting backup or network.
ios/Runner/Info.plist 78 Read in full. No NSAppTransportSecurity, no usage-description strings (nothing that would need one), no UIFileSharingEnabled.
ios/ tree find for PrivacyInfo.xcprivacy: absent (S13-F3).
pubspec.yaml · pubspec.lock 69 · — Every direct dependency listed; flutter pub deps --no-dev resolved and read in full; grepped both files for 12 analytics/crash/ads SDK names.
shared_preferences 2.5.5 Storage only. iOS pod ships a PrivacyInfo.xcprivacy declaring NSPrivacyAccessedAPICategoryUserDefaults, reason 1C8F.1. No network code in lib/.
path_provider 2.1.6 Read path_provider_android_real.dart:28-67 to establish the exact Android directory mapping for documents (getDir("flutter", MODE_PRIVATE)), cache and external-files. Its iOS pod ships no privacy manifest (S13-F3).
share_plus 13.3.0 No network code in lib/. Merges a FileProvider (dev.sergemio.cadence.flutter.share_provider) and a receiver into the manifest. Ships two privacy manifests.
device_info_plus 13.2.0 Android MethodCallHandlerImpl.kt and iOS FPPDeviceInfoPlusPlugin.m read in the relevant ranges; full field inventory in §4; its own privacy manifest quoted verbatim. No network code.
flutter_local_notifications 22.1.0 No network code in lib/. Contributes the two boot/scheduled receivers to the manifest. Ships privacy manifests.
timezone 0.11.1 Two library files contain network code (browser.dart:16, standalone.dart:41); confirmed the app imports neither, only data/latest.dart and timezone.dart.
audioplayers 6.1.0 → 6.8.1 audio_cache.dart:8,94 imports and calls http, inside if (kIsWeb). Confirmed unreachable. Its iOS pod ships no privacy manifest (S13-F3).
vibration 3.1.3 → 3.2.0 No network code, no storage. Ships a privacy manifest.
wakelock_plus 1.2.8 → 1.7.0 No network code, no storage of its own; pulls package_info_plus, whose only http use is its web implementation. Ships privacy manifests.
S13 — REFUTATION (data inventory and portability)agent_reports/S13_refute.md · raw .md

S13 — REFUTATION (data inventory and portability)

Role: adversarial refuter under R5. Subject: the app repository @ 03a176e72ef0075eec86b8915cbe6e93042a3b9d (v0.4.12+18). Evidence root: proof/01_findings/S13_refute/ · Captures: proof/03_market/captures/s813r_*. Mode: the pinned repo was never written to (proof/01_findings/S8_refute/pinned_repo_untouched.txt). Experiments ran on a copy at a scratch working copy with CADENCE_REPO set explicitly.

I did not read S9's report or its refuter's. Every conclusion below comes from Apple's and Google's own documentation, the packages in the local pub cache, and the shipped binaries.


Verdict table

Finding S13 severity Verdict Severity after refutation
S13-F1 — exported journal carries operator free text and a device description HIGH CONFIRMED HIGH
S13-F2 — Android auto-backup on by default HIGH FACTS CONFIRMED; store-declaration rationale REFUTED HIGH
S13-F3 — no PrivacyInfo.xcprivacy, "and its own code calls required-reason APIs" BLOCKER REFUTED LOW
S13-F4 — no config export or import HIGH CONFIRMED HIGH
S13-F5 — unmanaged external export copies, never deleted MEDIUM CONFIRMED, and under-stated HIGH
S13-F6 — corrupt siblings persist forever MEDIUM CONFIRMED MEDIUM
§3 — "no network, and on Android impossible" CONCLUSION CONFIRMED; PROOF METHOD INCOMPLETE n/a
§4 — device_info_plus reads and discards identifierForVendor / device name CONFIRMED at the cited lines n/a
§1 — "4 of 14 stored items are personal data under the GDPR" CLASSIFICATION UPHELD; ITS STATED PURPOSE REFUTED n/a

CONFIRMED: 4 · CONFIRMED-WITH-CORRECTION: 3 · REFUTED: 2 (S13-F3's severity and premise; S13-F2's Data Safety rationale). Findings I contributed: 5.


1. PRIORITY — ios/Runner/PrivacyInfo.xcprivacy

Verdict: NOT REQUIRED. S13-F3's BLOCKER is REFUTED. Correct severity: LOW.

S13-F3's severity rests on one factual premise: "Cadence's own Dart code reaches NSFileManager file-timestamp and disk-space APIs through dart:iof.exists(), f.length(), f.copy(), f.writeAsString() … which Apple classifies as required-reason API." That premise is false at the binary level, which is the level Apple's rule operates at.

Apple's operative sentence — the one neither stream quoted. From proof/03_market/captures/s813r_apple_required_reason_api.txt, URL https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api, retrieved 2026-08-04:

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.

The obligation attaches to the bundle containing the binary that references the API, not to the app as a whole. The same page carries the deadline S13 quoted correctly ("Starting May 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 sentence S13 leaned on ("If you use the API in your app's code, then you need to report the API in your app's privacy manifest file") — but the bundle sentence is what decides which binary "uses" it.

Which binary uses it — measured, not reasoned. dart:io's file layer is implemented in the Flutter engine, not in the compiled Dart. I confirmed this by disassembling the release build of this exact source (proof/01_findings/S13_refute/aot_vs_engine_symbols.txt):

lib/arm64-v8a/libapp.so
   undefined_dynamic_symbols = 0
   required_reason_FILE_TIMESTAMP_or_DISK_SPACE_symbols = []
lib/arm64-v8a/libflutter.so
   undefined_dynamic_symbols = 481
   required_reason_FILE_TIMESTAMP_or_DISK_SPACE_symbols = ['fstat', 'fstat64', 'lstat64', 'stat']

libapp.so is the ahead-of-time snapshot of Cadence's own Dart — every File.exists(), File.length(), File.copy() and File.writeAsString() in lib/journal.dart. It imports nothing. The stat family lives entirely in the engine. The iOS side is identical:

$ nm -u .../ios-release/Flutter.xcframework/ios-arm64/Flutter.framework/Flutter | grep stat
_fstat
_lstat
_stat

and that framework ships its own manifest, which declares exactly the category those symbols fall under (proof/01_findings/S13_refute/privacy_manifest_and_network.txt §3):

<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>0A2A.1</string><string>C617.1</string></array>
…
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<array><string>35F9.1</string></array>

The dynamic library that uses the API is Flutter.framework/Flutter, and its bundle already includes a manifest reporting it. Apple's rule is satisfied without an app-level file.

Every other covered category, per package, opened in the pub cache. From proof/01_findings/S13_refute/privacy_manifest_and_network.txt §1, by path:

Package (resolved version) Ships PrivacyInfo.xcprivacy? Declares
shared_preferences_foundation-2.5.6 Yesdarwin/…/Sources/shared_preferences_foundation/Resources/PrivacyInfo.xcprivacy NSPrivacyAccessedAPICategoryUserDefaults (1C8F.1)
flutter_local_notifications-22.1.0 Yesios/…/Sources/flutter_local_notifications/PrivacyInfo.xcprivacy NSPrivacyAccessedAPICategoryUserDefaults (CA92.1)
device_info_plus-13.2.0 Yes nothing (all arrays empty)
share_plus-13.3.0 Yes nothing
wakelock_plus-1.7.0 Yes nothing
package_info_plus-10.2.1 Yes nothing
vibration-3.2.0 Yes nothing
path_provider_foundation-2.6.0 No
audioplayers_darwin-6.5.0 No
timezone-0.11.1, audioplayers-6.8.1 No (pure Dart)

S13's per-pod table is correct. What it did not do is ask whether the two packages without a manifest owe one.

  • path_provider_foundation-2.6.0 ships no native target at all. It has no ios/ and no darwin/ directory, no .podspec, and registers as dartPluginClass: PathProviderFoundation — pure Dart over FFI through objective_c. It is correspondingly absent from ios/Runner/GeneratedPluginRegistrant.m (grep count 0), unlike the eight plugins that are there. Its FFI bindings reference zero required-reason API names (grep count 0 in both lib/src/ffi_bindings.g.dart and lib/src/path_provider_foundation_real.dart); it calls NSSearchPathDirectory / NSFileManager URL lookups, none of which is a covered API. There is no bundle to attach a manifest to and nothing to declare.
  • audioplayers_darwin-6.5.0 uses no required-reason API. Grepping its five Swift sources for every covered symbol family (UserDefaults, systemUptime, mach_absolute_time, creationDate, modificationDate, getattrlist, statfs, statvfs, volumeAvailableCapacity, activeInputModes) exits 1. Apple requires a manifest from an SDK only if it uses a covered API, collects data, contacts tracking domains, or appears on the commonly-used-SDK list — and audioplayers is not on that list.

Apple's commonly-used-SDK list, checked package by package (capture s813r_apple_third_party_sdk_requirements.txt, URL https://developer.apple.com/support/third-party-SDK-requirements/, retrieved 2026-08-04). Nine of Cadence's dependencies appear on it: device_info_plus, Flutter, flutter_local_notifications, package_info_plus, path_provider, path_provider_ios, share_plus, shared_preferences_ios, wakelock. Every one that ships a binary into the app ships a manifest. The single listed name with no manifest is path_provider, whose current iOS implementation ships no binary — the requirement is on SDK bundles, and there is no bundle.

The app's own native code touches nothing covered. ios/Runner/AppDelegate.swift (AVFoundation, UIKit, AVSpeechSynthesizer, AVAudioSession), ios/Runner/SceneDelegate.swift (empty subclass) and ios/Runner/GeneratedPluginRegistrant.m, grepped for the full covered-symbol set: exit 1.

Data collection is the other half of a manifest, and it is empty too. Apple defines the term (s813r_apple_app_privacy_details.txt, URL https://developer.apple.com/app-store/app-privacy-details/, retrieved 2026-08-04):

"Collect" refers to transmitting data off the device in a way that allows you and/or your third-party partners to access it for a period longer than what is necessary to service the transmitted request in real time.

Cadence transmits nothing (§3). NSPrivacyCollectedDataTypes would therefore be an empty array. And privacy-manifest-files is permissive about the app's own file: "Apps and third-party SDKs … can contain a privacy manifest file". The mandatory language ("You need to include a privacy manifest file in your third-party SDK if …") is scoped to SDKs.

Severity. BLOCKER under R13 means "prevents store submission". Nothing here prevents submission. Adding a manifest that declares an empty NSPrivacyAccessedAPITypes and an empty NSPrivacyCollectedDataTypes is harmless and arguably tidy, but Apple rejects invalid manifests ("App Store Connect rejects app submissions that include invalid privacy manifest files"), so shipping one is a small new risk in exchange for no benefit. LOW. S13-F3's own "How to prove the fix" concedes the point: its only stated test is find ios -name 'PrivacyInfo.xcprivacy' returning nothing, which tests the file's existence, not any submission requirement.

What would flip this verdict, precisely. An App Store Connect upload returning ITMS-91053 ("Missing API declaration") naming a category and a binary inside Runner.app that is not Flutter.framework, shared_preferences_foundation or flutter_local_notifications. That requires the artifact this workspace does not have — a Mac with Xcode. Until then the binary evidence above is decisive and the file is not a blocker.


2. The no-network claim — safe for a store listing, with one qualification

Conclusion CONFIRMED. Proof method INCOMPLETE — S13's four layers miss a whole class of network path. I re-ran the load-bearing layers independently rather than reading S13's proof files.

Layer 2, re-derived with a real AXML decoder. S13 detected permissions by scanning the binary manifest for byte strings. I decoded it properly with apkanalyzer manifest print (proof/01_findings/S13_refute/apk_manifest_decoded.txt):

android:targetSdkVersion="36"   android:minSdkVersion="24"
uses-permission: VIBRATE, WAKE_LOCK, MODIFY_AUDIO_SETTINGS, POST_NOTIFICATIONS,
                 USE_EXACT_ALARM, SCHEDULE_EXACT_ALARM (android:maxSdkVersion="32"),
                 USE_FULL_SCREEN_INTENT, RECEIVE_BOOT_COMPLETED,
                 dev.sergemio.cadence.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION

grep -icE 'allowBackup|dataExtractionRules|fullBackupContent|permission.INTERNET| ACCESS_NETWORK_STATE|usesCleartextTraffic|networkSecurityConfig' over the decoded manifest returns 0. No INTERNET permission. Confirmed by an independent method.

The fifth layer S13 did not run — see S13-R1. A package:http grep cannot see a plugin that reaches the network through the platform media stack. Two of Cadence's plugins do exactly that.

Is the claim safe for a store listing? Yes, with a per-platform qualification, and the copy S13 proposed is very nearly right. What is provable today:

  • Android — provable at the OS level. Without android.permission.INTERNET the kernel refuses every socket the process opens, whatever the Dart, Kotlin or native code attempts. This survives a future dependency adding network code. Say it plainly.
  • iOS — provable at the code level only. iOS has no equivalent permission. The guarantee is that no first-party code calls the network, no plugin's reachable code path does, and no analytics or crash SDK is present. That is a strong claim, but it is an argument about code rather than a capability the OS enforces, and one dependency upgrade could change it silently.

Wording I would put in a listing: Cadence works entirely offline. It contains no analytics and no crash reporting, and it sends nothing anywhere. On Android the app does not request internet permission at all, so the operating system cannot let it connect. Sentence three is the differentiator and it is the one that is machine-checkable — a restaurant's IT person can verify it from the Play listing's own permission section.

What must NOT be claimed: that nothing leaves the tablet. Two things do. The operator can export the journal to any app they choose (S13-F1), and Android Auto Backup is currently uploading the preferences, the journal, and every accumulated export copy to the tablet owner's Google Drive (S13-F2, S13-R3). S13 stated this correctly and I endorse it.

A regression test worth owning. The absence of INTERNET should be asserted in CI, not re-discovered per audit: a test that parses the merged release manifest and fails if android.permission.INTERNET ever appears. It is the cheapest possible guard on the single most commercially valuable property of the product, and it does not exist today.


3. Findings the stream MISSED

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

  • Severity: MEDIUM (the conclusion survives; the proof does not cover the case)
  • Location: lib/audio/audio.dart:73; audioplayers_darwin-6.5.0/darwin/…/WrappedMediaPlayer.swift:165-185; audioplayers_android-5.3.0/android/src/main/kotlin/xyz/luan/audioplayers/source/UrlSource.kt (valid at 03a176e)
  • What is wrong: S13 §3 layer 3 enumerates packages that import 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. Grepping every plugin's native sources for URLSession|NSURLConnection|CFNetwork|HttpURLConnection|OkHttp|java.net.Socket|Retrofit|Volley| loadUrl|AVURLAsset finds two hits that layer 3 would never surface.
  • Evidence: 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:

      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. - Why it matters for a restaurant kitchen: it does not change what the app does today. It changes whether the claim survives contact with a reviewer. This claim is going into a store listing and a sales conversation; the first competent person who asks "how do you know a plugin isn't phoning home?" will not accept a package:http grep, because a media plugin does not use package:http. The answer needs to be "we checked the native sources too, and the only network-capable path is a media source we never construct". - Proposed fix: 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 the fix: 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-R2 — S13-F5 and S13-F2 compound into silent loss of the only configuration backup

  • Severity: HIGH (S13 rated F5 MEDIUM in isolation)
  • Location: lib/journal.dart:25-26, :221-231 (valid at 03a176e)
  • What is wrong: three facts that are each in S13's report but never multiplied together. (a) Every export writes a new dated copy into getExternalStorageDirectory() and nothing ever deletes any of them (S13-F5). (b) That directory is Context.getExternalFilesDir(null), and Android's Auto Backup documentation lists "Files on external storage in the directory returned by getExternalFilesDir(String)" among the directories it backs up by default. (c) Auto Backup is capped at 25 MB per app, and past that the system stops backing the app up entirely. The journal rotates at 3 MB, so each export copy is between 1 and 3 MB. Roughly nine exports put the app over the quota, at which point onQuotaExceeded() fires and cloud backup stops — with no notification to anyone. A pilot kitchen exporting daily crosses it inside a fortnight.
  • Evidence: lib/journal.dart:25-26:
  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:

      // 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.

  • Why it matters for a restaurant kitchen: a separate refuter established that Auto Backup is currently the only mechanism carrying a kitchen's configuration across a device swap. This finding says that mechanism has a self-destruct: the diagnostic feature Serge asks pilot kitchens to use is filling the same quota the configuration recovery depends on, and when it breaks, it breaks silently and stays broken. The kitchen learns about it on the day the tablet is replaced.
  • Proposed fix: 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 the fix: 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-R3 — S13-F2's store-declaration rationale is wrong, and following it would over-declare on both stores

  • Severity: MEDIUM (the finding's facts are right; its reasoning would produce a false store filing)
  • Location: S13-F2 "Why it matters"; S13 §1 preamble (valid at 03a176e)
  • What is wrong: S13-F2 argues "Google Play's Data Safety form asks whether data is transferred, and a cloud backup the app enables by default is a fact the form has to reflect." Google's published guidance says the opposite on both counts. S13 §1 further frames its whole personal-data table as "the input to Phase 2's Google Play Data Safety form and Apple privacy nutrition labels". It is not; it is a GDPR-controller inventory, which is a different question with a different answer.
  • Evidence: 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. - Why it matters for a restaurant kitchen: getting this backwards is expensive in the direction nobody checks. Declaring "Files and docs — collected, transferred to third parties" on a Play listing for an app that transmits nothing would put a data-collection badge on the store page of a product whose single strongest selling point is that it collects nothing, and it would be a false statement about Google's defined term. The correct filings are "No data collected or shared" on Play — with the form still completed and a privacy policy still linked, which Google requires of every app including those that collect nothing — and "Data Not Collected" on the App Store. - What survives, and it is the real finding. The privacy fact in S13-F2 is untouched and remains serious: a diner's name typed into a timer name on Friday is in a Google backup by Saturday, the restaurant did not choose it and cannot see it, and the honest answer to "does anything leave the tablet?" in a sales meeting is still "yes, to the tablet owner's own Google account, by default". That is a GDPR question for the restaurant as controller and a product question for Serge. Keep the finding at HIGH; re-base it on that, not on the form. - Proposed fix: 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 the fix: not a code test — the two quoted definitions above are the artifact.

S13-R4 — The permission enumeration is wrong in both directions

  • Severity: LOW
  • Location: S13 §3 layer 2 (valid at 03a176e)
  • What is wrong: S13 prints a nine-line list of android.permission.* entries including android.permission.DUMP, then correctly notes in a parenthesis that DUMP "is not the app's". The list and the parenthesis contradict each other, and the list also omits a permission the app really does request. The decoded manifest shows eight android.permission.* requests plus one custom dev.sergemio.cadence.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION; DUMP appears only as android:permission="android.permission.DUMP" guarding the androidx.profileinstaller ProfileInstallReceiver, which is a guard on an exported receiver, not a request. S13 also flattens SCHEDULE_EXACT_ALARM, which ships with android:maxSdkVersion="32".
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: the permission list is the artifact a restaurant's IT person will actually look at on the Play listing, and it is the evidence behind the offline claim. It has to match what the store shows, permission for permission.
  • Proposed fix: 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 the fix: re-run apkanalyzer manifest print on the release APK and diff.

S13-R5 — Two wrong file:lines, two wrong line counts, and an internal 13-vs-14 contradiction

  • Severity: LOW
  • Location: S13-F2 "Location"; S13 §7 coverage manifest; S13 §6 (valid at 03a176e)
  • What is wrong:
  • S13-F2 locates the <application> element at android/app/src/main/AndroidManifest.xml:20-23. It spans :18-21; lines 22-23 are <activity and android:name=".MainActivity".
  • AndroidManifest.xml is 79 lines, not 74. ios/Runner/Info.plist is 70, not 78. Both figures are correct in research/00_code_map.md:1644,1647, the shared map every stream was told to use rather than re-derive.
  • §1 concludes "4 of 14 stored items", while §6 states "All persistence is the 13 items in §1". The table has 14 rows, one of which (item 9, cadence-zones-v1) is deleted by the one-shot migration at store.dart:283 and so is not a stored item on any current install.
  • Evidence: 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.
  • Why it matters for a restaurant kitchen: it does not — it matters because S13's inventory is the input to a store filing and a GDPR answer, and a count that contradicts itself two sections apart is the first thing a reviewer notices.
  • Proposed fix: 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 the fix: re-run the line counts and reconcile §1 with §6.

4. What I attacked and could not break

  • S13-F1. Read lib/journal.dart:205-237 and lib/ui/modals.dart:696-717. exportCopy() copies the whole journal and returns its path; the share call passes the file plus a subject and body with no redaction and no preview. Confirmed, including that Cadence chooses no destination.
  • S13-F4. Re-ran the export/import grep over lib/ myself. The only export in the app is lib/journal.dart:207 exportCopy(); there is no import path of any kind, and no share_plus call site other than lib/ui/modals.dart:705. Confirmed.
  • S13-F6. Read lib/engine/store.dart:119-128. _preserveCorrupt writes '<key>.corrupt' only if absent, and nothing anywhere removes it. Confirmed.
  • device_info_plus §4, at the exact lines cited. ios/…/FPPDeviceInfoPlusPlugin.m:59 @"name" : [device name] and :65 @"identifierForVendor" : [[device identifierForVendor] UUIDString]; Android MethodCallHandlerImpl.kt:44 build["name"] = Settings.Global.getString(contentResolver, Settings.Global.DEVICE_NAME) ?: "". All three are where S13 says they are. lib/journal.dart:114-130 keeps six Android fields and four iOS fields and discards the rest, including both identifiers. S13's conclusion is right and the reasoning behind it is right for a reason worth stating explicitly: a value read into memory and discarded is not "collection" under either store's definition, because neither definition turns on reading — both turn on transmitting off the device, quoted in §1 and §3 above. The honest declaration is no device identifier collected. The one live risk S13 identifies — that IosDeviceInfo.name may return "iPad de Serge" and Cadence does write that into the journal — is real, is a person's name in an emailable file, and its missing artifact (an iOS build on a physical iPad) is named correctly.
  • The allowBackup default. Verified independently against https://developer.android.com/guide/topics/manifest/application-element (capture s813r_android_application_element.txt, retrieved 2026-08-04): "Whether to let the application participate in the backup and restore infrastructure. … The default value of this attribute is "true"." And: "for apps targeting Android 12 (API level 31) or higher … you can't disable device-to-device migration of your app's files", with the app at targetSdkVersion="36". The scope claims hold too — Auto Backup includes shared preferences and getDir(String, int) (where the journal lives) and excludes getCacheDir() (where the temp export copy lives), so S13's "only the exported copy in the temp directory escapes it" is exactly right.
  • The "4 of 14" personal-data classification itself. I pushed on whether free text a user may type is enough to make a field personal data, and concluded S13 is right to say yes for a GDPR inventory: the GDPR test is whether the data relates to an identifiable natural person, and a field that is unconstrained, unvalidated, unredacted, exported on demand and correlated to a device description and a minute-by-minute timeline will contain such data as soon as one cook types Table 12 Mme Dupont — which S13's own probe shows the app does nothing to prevent. Treating the field as personal data is the correct controller-side posture. What is over-inclusive is not the classification but its stated destination — S13-R3.
  • Layer 4 (no analytics or crash SDK). Re-grepped pubspec.lock as well as pubspec.yaml. Nothing. Corroborated from the other direction by the decoded APK manifest, which contains no Firebase, Crashlytics, or ads component.

5. Coverage manifest — every file and artifact in S13's scope

File / artifact Lines (measured) What I checked in it
lib/journal.dart 250 Read :60-80, :108-135, :160-240. Traced every dart:io call (exists, create, length, writeAsString, readAsString, copy) and established none reaches a required-reason symbol in the app's own binary. Confirmed the 3 MB / 1 MB rotation constants (:25-26), the dated external copy (:227-231), the export filename pattern (:221-223), and _describeDevice (:114-130). Basis of S13-R2.
lib/engine/store.dart 354 Read :12-30 (all nine key constants) and :119-128 (_preserveCorrupt) and :255-285 (the zones migration that deletes cadence-zones-v1 at :283). Reconciled the item count against §1 and §6 — S13-R5.
lib/ui/modals.dart 746 Read :696-717. Confirmed the SharePlus.instance.share(...) call at :705-711 is the only share site in lib/, and that no preview or redaction precedes it.
lib/audio/audio.dart 116 Read the source-construction site. AssetSource at :73 is the only audio source the app ever builds — the reachability half of S13-R1.
lib/diagnostics.dart 54 Read in full. Diag is capped in RAM; its only persistence is Journal.log. No file, no transmission.
lib/ui/home.dart 722 Read :76-84 (migration call order) and the Journal.log sites carrying operator data.
lib/audio/voice.dart · lib/alarm_backstop.dart · lib/main.dart · lib/engine/engine.dart · lib/engine/models.dart · lib/i18n.dart · lib/ui/theme.dart · lib/ui/grid_layout.dart · lib/ui/header.dart · lib/ui/tile.dart · lib/ui/logo.dart · lib/audio/alarm_volume.dart 204 · 279 · 58 · 432 · 160 · 167 · 82 · 109 · 215 · 819 · 18 · 68 Checked each for storage, file I/O and network. None has any. Corroborated by the File( sites resolving only to journal.dart and the XFile wrapper, and by the app-side network grep returning a single AssetSource line.
ios/Runner/AppDelegate.swift 207 Read in full. AVFoundation / UIKit / AVSpeechSynthesizer / AVAudioSession only. Grepped for the complete required-reason symbol set: exit 1. No file API, no UserDefaults, no disk-space call, no boot time, no network.
ios/Runner/SceneDelegate.swift 6 Read in full. Empty FlutterSceneDelegate subclass.
ios/Runner/GeneratedPluginRegistrant.m 70 Read. Registers 8 plugins; path_provider is absent, confirming its dartPluginClass (pure-Dart) registration. Grepped for required-reason APIs: exit 1.
ios/Runner/Info.plist 70 (S13 said 78) Read in full. No NSAppTransportSecurity, no usage-description strings, no UIFileSharingEnabled.
ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift 25 Read. Confirms the project builds plugins through Swift Package Manager rather than CocoaPods, which is why plugin privacy manifests ship as SPM .process resources — the pattern Apple documents for statically linked SDKs.
ios/ tree find for PrivacyInfo.xcprivacy: absent. Established this is not a submission blocker (§1).
android/app/src/main/AndroidManifest.xml 79 (S13 said 74) Read in full. <application> at :18-21, carrying only android:label, android:name, android:icon. No allowBackup, no dataExtractionRules, no fullBackupContent.
Release APK (app-release.apk) 53,629,091 bytes Decoded the binary manifest with apkanalyzer manifest print — an independent method from S13's byte scan. Enumerated all nine <uses-permission> elements; verified the absence of INTERNET, ACCESS_NETWORK_STATE, allowBackup, dataExtractionRules, fullBackupContent, usesCleartextTraffic, networkSecurityConfig; confirmed targetSdkVersion="36". Extracted lib/arm64-v8a/ and compared the undefined dynamic symbols of libapp.so (0) against libflutter.so (481, including the stat family).
pubspec.yaml · pubspec.lock 68 · — Re-grepped both for the analytics/crash/ads SDK names. Absent.
path_provider_foundation-2.6.0 Opened the package. No ios/, no darwin/, no .podspec, dartPluginClass: PathProviderFoundation, dependencies ffi + objective_c. Zero required-reason API names in ffi_bindings.g.dart and path_provider_foundation_real.dart. Read getTemporaryPath/getApplicationDocumentsPathNSSearchPathDirectory lookups, none covered.
audioplayers_darwin-6.5.0 Opened the package. No privacy manifest, and no required-reason API (grep exit 1), so none is owed; audioplayers is not on Apple's commonly-used-SDK list. Read WrappedMediaPlayer.swift:165-185 — the AVURLAsset remote path behind S13-R1.
shared_preferences_foundation-2.5.6 Manifest read verbatim: NSPrivacyAccessedAPICategoryUserDefaults, reason 1C8F.1, declared as an SPM .process("Resources") resource. Confirmed UserDefaults.standard.set in SharedPreferencesPlugin.swift.
flutter_local_notifications-22.1.0 Manifest read verbatim: NSPrivacyAccessedAPICategoryUserDefaults, reason CA92.1, declared as .process("PrivacyInfo.xcprivacy"). Confirmed [NSUserDefaults standardUserDefaults] in FlutterEngineManager.m:33 and FlutterLocalNotificationsPlugin.m:313,392,670. Native network grep: none.
device_info_plus-13.2.0 Manifest read verbatim: all arrays empty, NSPrivacyTracking false. FPPDeviceInfoPlusPlugin.m:59,65 and MethodCallHandlerImpl.kt:44 read at the cited lines. Native network grep: none.
share_plus-13.3.0 · wakelock_plus-1.7.0 · package_info_plus-10.2.1 · vibration-3.2.0 Manifests read: each ships one, each declares nothing. Native network grep: none for any.
path_provider_android-2.3.1 · shared_preferences_android-2.4.27 · audioplayers_android-5.3.0 Native network grep: UrlSource.kt in audioplayers_android only (S13-R1); the other two clean.
timezone-0.11.1 · audioplayers-6.8.1 Pure Dart, no native target, no manifest owed. Confirmed the app imports only timezone/data/latest.dart and timezone/timezone.dart.
Flutter engine (ios-release/Flutter.xcframework/ios-arm64/Flutter.framework) Read its PrivacyInfo.xcprivacy verbatim and ran nm -u on the binary. It imports _stat, _lstat, _fstat and declares NSPrivacyAccessedAPICategoryFileTimestamp (0A2A.1, C617.1) and NSPrivacyAccessedAPICategorySystemBootTime (35F9.1). The decisive evidence in §1.
Apple documentation 6 captures describing-use-of-required-reason-api, privacy-manifest-files, adding-a-privacy-manifest-to-your-app-or-third-party-sdk, describing-data-use-in-privacy-manifests, app-privacy-details, third-party-SDK-requirements. All under proof/03_market/captures/s813r_apple_*, all retrieved 2026-08-04 via utilities/chrome.py per R4.
Google documentation 3 captures Play Data safety (twice — once as served, once with every accordion expanded, because the operative definitions are inside collapsed sections), Android Auto Backup, Android <application> element. proof/03_market/captures/s813r_play_*, s813r_android_*, retrieved 2026-08-04.

Files and artifacts in S13's scope I did not open: none.

Stream S14: finding and refutation

S14 — App entry, startup ordering, and every file no other stream ownsfindings/S14_entry_unowned.md · raw .md

S14 — App entry, startup ordering, and every file no other stream owns

Subject: the app repository at 03a176e72ef0075eec86b8915cbe6e93042a3b9d Stream scope: lib/main.dart, analysis_options.yaml, ios/Runner/SceneDelegate.swift, ios/RunnerTests/RunnerTests.swift, ios/Runner/Base.lproj/*.storyboard, ios/Runner/Assets.xcassets/LaunchImage.imageset/**, android/app/src/main/res/**, web/, tools/build_ringtones.py, .gitignore, .metadata, README.md, pubspec.yaml, pubspec.lock — plus everything in git ls-files that S1–S13 do not cover. Proof directory: proof/01_findings/S14/ Work surface: read-only copies at a scratch working copy and a scratch working copy. Nothing under cadence-app was modified (R10).

Findings: 11 — 0 BLOCKER · 2 HIGH · 6 MEDIUM · 3 LOW.


Findings

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

  • Severity: HIGH
  • Location: lib/main.dart:22-35 (valid at 03a176e), specifically :24
  • What is wrong: main() has no try anywhere. await Store.open() (:24) is the first thing that touches the platform, and Store.open() is a bare Store(await SharedPreferences.getInstance()) (lib/engine/store.dart:29-30) with no error handling of its own. If SharedPreferences.getInstance() throws — a corrupt or locked prefs XML, a plugin registration failure, an OS-level storage error during a device-storage hiccup — the exception propagates out of main(), runApp at :34 is never reached, and no Flutter frame is ever drawn. The Android window keeps the NormalTheme window background (android/app/src/main/res/values/styles.xml:15-17, android:windowBackground = ?android:colorBackground), so the cook sees a plain, empty, text-free window carrying the platform's light or dark window background, indefinitely. Nothing reaches Diag, so the banner never appears; nothing reaches Journal, because Journal.init is on the next line and never runs — so the flight recorder that exists precisely to diagnose field failures records nothing about the one failure that stops the app entirely.
  • Evidence: 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(). - Why it matters for a restaurant kitchen: a tablet that shows a blank grey/white rectangle at 18:30 is a total product failure with zero diagnostic surface. Nobody on site can distinguish it from a dead tablet, a dead app, or a dead battery; the flight-recorder export in Settings is unreachable because Settings is unreachable; and the exported journal from the previous session will end mid-service with no line explaining the next boot. The whole field-diagnosis apparatus the team built (lib/journal.dart, 250 lines) is bypassed by the one failure that matters most. - Proposed fix: 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 the fix: 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

  • Severity: HIGH
  • Location: lib/main.dart:24-25 (valid at 03a176e)
  • What is wrong: F1 covers the throw path. This is the hang path, and it is not fixed by a try. await Store.open() (:24) and await Journal.init(...) (:25) both cross a platform channel, and neither has a .timeout(...). Journal.init awaits, in sequence, getApplicationDocumentsDirectory() (lib/journal.dart:69), f.exists(), possibly f.create(), f.length(), _describeDevice() — which is a device_info_plus platform call (lib/journal.dart:114-131) — then prefs.setBool, prefs.setInt and _flush(), a disk write. That is at least seven awaited platform or disk operations gating the first frame, on top of SharedPreferences.getInstance(). A channel that never replies (rather than replying with an error) leaves main() suspended forever: no exception, so try/catch cannot help, and runApp is never reached. grep proves there is no timeout anywhere in the file, and no watchdog anywhere in lib/.
  • Evidence: .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).
  • Why it matters for a restaurant kitchen: identical operator experience to F1 — a blank window during service — but with a worse property: it is not reproducible on demand and leaves no stack trace, so a support call produces "it just didn't start" and nothing else. The !! SESSION PRECEDENTE TUEE line (lib/journal.dart:88-90) that would normally reveal a killed session is not written either, because the journal never initialised.
  • Proposed fix: 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 the fix: 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.

S14-F3 — main() installs no global error handler, so every uncaught error in the app is invisible to the flight recorder

  • Severity: MEDIUM
  • Location: lib/main.dart:22-35 (valid at 03a176e) — the absence is the finding
  • What is wrong: runZonedGuarded, FlutterError.onError and PlatformDispatcher.instance.onError are the three places Flutter lets an application catch what its own try blocks missed. None of the three is installed anywhere in the repository. main() is where they go. The consequence is concrete and not theoretical for this product: the app is built around a persistent on-device journal (lib/journal.dart) whose entire purpose is to explain a field failure after the fact, and every failure that does not pass through one of the 38 hand-written catch sites is missing from it. Widget build errors in lib/ui/tile.dart (819 lines, 0.00% covered, zero error handling per research/00_code_map.md §3.3) go to the console and to the red/grey error box, and never to the journal the operator emails back.
  • Evidence: $ grep -rn "runZonedGuarded\|FlutterError.onError\|PlatformDispatcher.instance.onError\|ErrorWidget.builder" lib/ test/ (no output) The only onError string in lib/ is a comment: lib/audio/voice.dart:162, // Completes when the utterance finishes (onDone/onError/onStop native.
  • Why it matters for a restaurant kitchen: the team's own stated lesson is that the journal is the deliverable of the tablet campaign (research/01_prior_work.md §L13). A crash class that never reaches it makes the campaign structurally unable to close. Commit f46d142 already records four unproven failure modes; an unlogged widget exception would present as one of them and be chased in the wrong file.
  • Proposed fix: in main(), before runApp, set FlutterError.onError = (d) { Diag.fail('flutter-error', d.exception); FlutterError.presentError(d); }; and PlatformDispatcher.instance.onError = (e, s) { Diag.fail('platform-error', e); return true; }; Both route into the existing single choke point (lib/diagnostics.dart:28) and therefore into the journal with no new plumbing. S5 owns the repository-wide error-handling verdict; this finding owns only the fact that main() installs nothing.
  • How to prove the fix: a widget test that pumps a widget whose build throws, then asserts Diag.log contains a flutter-error entry. Red at 03a176e (Diag.log stays empty), green after.

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

  • Severity: MEDIUM
  • Location: lib/main.dart:26-32, lib/ui/home.dart:670-707 (valid at 03a176e)
  • What is wrong: the comment at lib/main.dart:27 promises "A failing wakelock = the screen may sleep mid-service → operator banner". The banner is real. Diag.fail('wakelock', e, isCritical: true) (:31) adds 'wakelock' to Diag.critical (lib/diagnostics.dart:32-34); _criticalBanner() (lib/ui/home.dart:670) listens to that notifier, maps scope.startsWith('wakelock') to i18n.call('screenDown') (:683-684), and is mounted in the build tree at lib/ui/home.dart:565, directly under the header. Verified end-to-end by test, not by reading. Two real defects remain around it. (a) It can never clear: Diag.clearCritical('wakelock') is called nowhere — grep for clearCritical in lib/ returns five call sites (alarm_backstop.dart:91,202,203, audio/audio.dart:74, voice.dart:57, engine/store.dart:139) and none is the wakelock. Every other critical capability in the app has a recovery path; this one does not. (b) Success is assumed, never verified: WakelockPlus.enable() resolving means the call was accepted, not that the screen will stay on. WakelockPlus.enabled is never queried anywhere in lib/, and enable() is called exactly once, at boot, with no re-assertion on AppLifecycleState.resumed — even though _HomeScreenState already implements didChangeAppLifecycleState (lib/ui/home.dart:175). A wakelock dropped by a manufacturer battery saver after two hours of service produces a sleeping screen and a clean, empty banner area.
  • Evidence: 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 may 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((_) {
  • Why it matters for a restaurant kitchen: a sleeping screen mid-service is the failure mode the wakelock exists to prevent, and the board is a wall-mounted display nobody touches for minutes at a time. A banner that cannot clear trains the cook to ignore banners — which is exactly how the backstopDown and audioDown banners lose their meaning too. A wakelock that fails after boot is the more likely of the two failures on a manufacturer-skinned Android tablet, and it is the one with no detection at all.
  • Proposed fix: 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 the fix: 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-F5 — analysis_options.yaml is the untouched Flutter template: flutter analyze reporting 0 issues measures almost nothing

  • Severity: MEDIUM
  • Location: analysis_options.yaml:1-29 (valid at 03a176e)
  • What is wrong: the file is byte-identical to a freshly generated flutter create template. It includes package:flutter_lints/flutter.yaml and its linter: rules: block (:23-25) contains only the two commented-out examples the template ships with — not one rule is enabled or disabled. flutter_lints 6.0.0 adds ten Flutter-specific rules on top of package:lints/recommended.yaml, and none of them looks at async correctness, error handling, or future discipline. The baseline fact "flutter analyze → 0 issues, --fatal-infos --fatal-warnings also 0" therefore certifies a very small surface. Turning on six well-chosen rules produces 75 issues in this codebase today, two of them inside lib/main.dart itself.
  • Evidence: 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. - Why it matters for a restaurant kitchen: the analyser is the only gate that runs on every change without a human. With the template configuration it cannot see the class of defect this product actually suffers from — dropped futures on the alarm path and swallowed exceptions. A commercial product shipping to two app stores gets one cheap, permanent, zero-runtime-cost check, and this one is currently switched almost off. - Proposed fix: 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. - How to prove the fix: 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.


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

  • Severity: MEDIUM
  • Location: README.md:30-34 (valid at 03a176e)
  • What is wrong: README.md was last modified in commit 22902e0 ("Cadence v0.2.0"), the very first commit, and has survived 17 subsequent commits up to v0.4.12 unchanged. Its "Écarts connus vs spec (v0.1)" section states verbatim: «Alarmes en arrière-plan / app tuée : pas encore branchées (packages alarm + flutter_local_notifications prévus)» — background and app-killed alarms not yet wired. They were wired: lib/alarm_backstop.dart is 279 lines of flutter_local_notifications scheduling with AndroidScheduleMode.alarmClock, and the whole Android half of prior finding A1-1 was closed by it (research/01_prior_work.md §2.1). Three more statements are now false: «Sélection de voix TTS = défaut OS (pas de scoring de voix comme le proto web)» — lib/audio/voice.dart:104-138 scores every voice on language, exact locale, Android Voice.quality and a network penalty; «modales (éditeur, zones, réglages)» and «schéma de données identique au proto (timer / zone / run / clone)» (README.md:16, :12) — zones were deleted in v0.4.11 (lib/engine/models.dart:6-13, lesson L1); «store.dart — persistance shared_preferences (4 clés)» (:14) — there are 9 declared keys plus a dynamic <key>.corrupt family; «test/engine_test.dart17 tests unitaires» (:18) — that file now declares 21, and the suite is 123.
  • Evidence: $ 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.`
  • Why it matters for a restaurant kitchen: the README is the front door. Anyone evaluating this product — a co-founder, a buyer's technical reviewer, a store reviewer following a repository link, or the next engineer — reads it and concludes the app does not ring when backgrounded. That is the exact question A1-R1 was decided on, and the answer in the code is the opposite of the answer in the README. It also under-sells the product's single strongest feature.
  • Proposed fix: 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).
  • How to prove the fix: 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.

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

  • Severity: MEDIUM
  • Location: web/index.html:21,32, web/manifest.json:2-9 (valid at 03a176e)
  • What is wrong: every one of the seven files under web/ is byte-identical to a freshly generated Flutter template. web/index.html:21 declares <meta name="description" content="A new Flutter project.">, :32 a lowercase <title>cadence</title>, :26 apple-mobile-web-app-title cadence. web/manifest.json carries "name": "cadence", "description": "A new Flutter project.", "background_color" and "theme_color" both #0175C2 — Flutter's brand blue, not the app's beige #F4EFE4 (lib/ui/theme.dart:5) — and "orientation": "portrait-primary", which is the wrong orientation for a wall-mounted kitchen board. The four PWA icons are Flutter's own logo. Nothing in the repository references the web target: no kIsWeb, no dart:html, no package:web, no flutter build web in README.md or anywhere else. The app cannot work on web in any case — the OS backstop is flutter_local_notifications with Android-only initialisation (lib/alarm_backstop.dart:73-76) and the alarm-volume and TTS paths are the app's own MethodChannels implemented only in Kotlin and Swift.
  • Evidence: 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)
  • Why it matters for a restaurant kitchen: it is not a runtime risk — nothing builds it. It is a credibility and hygiene risk: this is a public GitHub repository under Serge's name that will be linked from store listings and shown to buyers, and its manifest.json says the product is "A new Flutter project." It also keeps alive prior finding A1-5 / A1-R6, which research/01_prior_work.md records as STILL OPEN precisely here.
  • Proposed fix — binary recommendation: DELETE. Remove the web/ directory (7 files) and remove the - platform: web block from .metadata:18-20. The web target is dead weight for a product whose delivery decision (A1-R1) is native, whose alarm contract cannot be met on the web, and which is not built for web by any script or document in the repository. Fixing the metadata instead would mean maintaining a platform nobody ships. Deleting it also closes A1-5 / A1-R6 outright rather than half-closing it.
  • How to prove the fix: 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.

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

  • Severity: MEDIUM
  • Location: .metadata:13-20 (valid at 03a176e)
  • What is wrong: the migration: platforms: list contains exactly two entries: root and web. A flutter create project targeting Android and iOS registers android and ios here, and flutter migrate uses those entries to know which template files it is allowed to update. As committed, the two platforms the product actually ships on are invisible to that tool, while the one platform nothing uses is registered. Flutter template changes routinely carry mandatory platform updates (Gradle and Android Gradle Plugin bumps, new manifest attributes, Xcode project settings); none of them will ever be offered for android/ or ios/. The file's own header says it "should not be manually edited", and it has never been edited since commit 22902e0.
  • Evidence: 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 ```
  • Why it matters for a restaurant kitchen: indirect but real. Google Play raises its targetSdk floor annually and Apple raises its minimum Xcode/SDK; an app whose platform folders are outside the upgrade tool's reach accumulates manual migration debt that surfaces as a blocked release at exactly the moment a release is needed. android/app/build.gradle.kts already inherits minSdk/targetSdk/compileSdk from the Flutter plugin, so the toolchain is the only thing keeping them current.
  • Proposed fix: 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 the fix: flutter migrate --verbose lists android and ios among the platforms it considers. At 03a176e it does not.

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

  • Severity: LOW
  • Location: android/app/src/main/res/drawable/launch_background.xml:4, android/app/src/main/res/drawable-v21/launch_background.xml:4, android/app/src/main/res/values/styles.xml:4,15, android/app/src/main/res/values-night/styles.xml:4,15, ios/Runner/Base.lproj/LaunchScreen.storyboard:22 (valid at 03a176e)
  • What is wrong: all five files are the untouched Flutter template. On Android, LaunchTheme inherits @android:style/Theme.Light.NoTitleBar in values/ and @android:style/Theme.Black.NoTitleBar in values-night/; the launch drawable is @android:color/white pre-v21 and ?android:colorBackground from v21 — that is, the platform's light or dark background, never the app's colour. The app's first Flutter frame is C.bg = Color(0xFFF4EFE4) (lib/ui/theme.dart:5) under a near-black header C.headerBg = Color(0xFF191B14) (:27). The correct value already exists in the repository: values/colors.xml:3 declares <color name="ic_launcher_background">#F4EFE4</color> for the adaptive icon. So on a tablet with dark mode on, a cold start shows a full black screen, then jumps to beige. On iOS the same applies: LaunchScreen.storyboard:22 sets the view background to pure white and centres an image named LaunchImage whose three PNG files are identical 68-byte 1×1 placeholders (the storyboard's <image name="LaunchImage" width="168" height="185"/> at :35 describes an asset that is not there any more).
  • Evidence: 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).
  • Why it matters for a restaurant kitchen: it is cosmetic, which is why it is LOW, but it is visible on every single cold start on a device that is power-cycled daily, and the black-to-beige jump in dark mode reads as a glitch. It is also the first thing a store reviewer sees when they launch the build, and it is the cheapest quality signal in the whole product.
  • Proposed fix: 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.
  • How to prove the fix: 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.

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

  • Severity: LOW
  • Location: lib/main.dart:33, android/app/src/main/AndroidManifest.xml:22-30 (valid at 03a176e)
  • What is wrong: SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky) is called exactly once, in main(), and its returned Future is discarded (flagged by unawaited_futures at lib/main.dart:33:16 — see F5). The comment at lib/main.dart:26 describes this as "kiosk feel", and that is exactly the right word: it is a feel, not a kiosk. immersiveSticky hides the status and navigation bars and restores them temporarily on a swipe from the edge — during which the Android back and home buttons are live and tappable. Nothing in the repository prevents leaving the app: grep across lib/, android/ and ios/ finds no startLockTask, no lockTask, no screen-pinning call, no android:screenOrientation lock, no SystemChrome.setPreferredOrientations. The mode is also never re-applied — _HomeScreenState implements didChangeAppLifecycleState (lib/ui/home.dart:175) but does not call SystemChrome.restoreSystemUIOverlays() or re-set the mode on resume, so returning from a notification, a dialog, or the share sheet used by the journal export (lib/ui/modals.dart:700-714) can leave the bars visible.
  • Evidence: $ 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.
  • Why it matters for a restaurant kitchen: an accidental edge swipe followed by a tap on Home during service leaves the timer board and puts the app in the background. The OS backstop (lib/alarm_backstop.dart) is designed for exactly that case and will still ring — which is why this is LOW and not HIGH — but the board, which is how the cook reads eight simultaneous cooks at a glance, is gone until someone notices and reopens it. True kiosk on Android is available without extra permissions through Activity.startLockTask() (the user confirms pinning once) and fully through device-owner provisioning; neither is used or documented.
  • Proposed fix: 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 the fix: 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.

S14-F11 — ios/RunnerTests/RunnerTests.swift is a compiled test target whose single test asserts nothing

  • Severity: LOW
  • Location: ios/RunnerTests/RunnerTests.swift:7-10 (valid at 03a176e)
  • What is wrong: the file is the untouched Flutter template. testExample() has an empty body with two comment lines and no assertion. It is wired into the Xcode project (19 references to RunnerTests in ios/Runner.xcodeproj/project.pbxproj), so it is a real target that will build and pass in any future CI, contributing a green tick that measures nothing. R8 names this exact shape as a defect: "A test that passes without exercising the behaviour is a defect."
  • Evidence: the entire file, verbatim: ```swift import Flutter import UIKit import XCTest

class RunnerTests: XCTestCase {

func testExample() {
  // If you add code to the Runner application, consider adding tests here.
  // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}

} `` MarkedSTOCK-TEMPLATEinproof/01_findings/S14/stock_template_comparison.txt;grep -c "RunnerTests" ios/Runner.xcodeproj/project.pbxproj19. - **Why it matters for a restaurant kitchen:** no runtime effect. It matters becauseios/Runner/AppDelegate.swiftis 207 lines of channel code that, per commit47f4172, has **never been compiled** («JAMAIS COMPILE (pas de Mac)») — and the one place an iOS test could assert that thecadence/volumeandcadence/ttshandlers respond as the Dart side expects is this empty file. An empty green test target in that position is worse than no target, because it looks like coverage. - **Proposed fix:** either deletetestExample()and replace it with one real test — construct theAppDelegate,invokegetAlarmVolumeoncadence/volumeand assert it returnsnilasios/Runner/AppDelegate.swift:54-57specifies, which is the Dart-side contractlib/ui/home.dart:226depends 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. - **How to prove the fix:**xcodebuild test -workspace ios/Runner.xcworkspace -scheme Runneron a Mac with Xcode: the new test must fail ifAppDelegate.swift:54-57is changed to return a number instead ofnil`. Cannot be executed in this environment; the missing artifact is Xcode.


Verified clean — checked and no finding

Recorded so the coordinator can see these were examined rather than skipped.

Item Checked Result
tools/build_ringtones.py reproducibility ran it on a scratch copy with the workspace venv (numpy 2.4.2) and compared SHA-256 before/after All 13 files it writes are byte-identical to the committed assets — 12 tones in assets/audio/ plus android/.../res/raw/cadence_alarm.wav. Commit 8461639's byte-identity claim still holds today. Proof: proof/01_findings/S14/ringtones_regen.txt, ringtones_committed.sha256, ringtones_regenerated.sha256, ringtones_byte_identity.txt (EXIT_CODE=0, empty diff). It writes into assets/audio/ and android/.../res/raw/ (:49-51), so it was run only in a scratch working copy, never in the repo.
tools/build_ringtones.py vs the workspace Python framework judged per research/02_framework_routing.md §5a Divergences are cosmetic and out of scope: import wave, os (:44), no type hints, module-level executable code with bare print and no main() guard. This is Serge's file in Serge's repo and the workspace house style is not a standard he agreed to. Two facts worth carrying to S11: numpy is declared in no manifest (no requirements.txt, no pyproject.toml), and the header run instruction python app/tools/build_ringtones.py (:42) implies a parent working directory — but the script resolves its own paths from __file__ (:48), so it runs correctly from anywhere, verified. The 3 remaining WAVs on disk (step.wav, click-up.wav, click-down.wav) have no generator here, confirming the code map.
test/version_test.dart genuinely catches version drift R8 mutation, both directions YES — it bites. Mutating kAppVersion '0.4.12''0.4.13' makes it red (Expected: '0.4.12' / Actual: '0.4.13', EXIT_CODE=1, proof/01_findings/S14/version_test_red_mutated.txt); mutating pubspec.yaml version: 0.4.12+180.4.13+19 also makes it red (Expected: '0.4.13' / Actual: '0.4.12', proof/01_findings/S14/version_test_red_pubspec_bumped.txt). Green before and after restore (version_test_green_before.txt, version_test_green_after.txt). Mutation patch saved at proof/01_findings/S14/version_mutation.patch. Lesson L3 is enforced, not merely promised.
MediaQuery.withNoTextScaling applies to the whole tree test T4, recorded green Confirmed. Wrapping CadenceApp in a MediaQuery with TextScaler.linear(3.0) and reading MediaQuery.textScalerOf(...) from the element of HomeScreen returns TextScaler.noScaling. The builder at lib/main.dart:48 sits above the Navigator, so every route and every dialog inherits it. Mechanism established; S4 owns the accessibility trade-off.
android/app/src/main/res/raw/keep.xml read in full Correct and well-reasoned. tools:keep="@raw/cadence_alarm" pins the backstop sound against the release resource shrinker, with the incident that motivated it recorded in the file («v0.3 first build shipped without it»). No finding.
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml read in full Correct adaptive icon: background @color/ic_launcher_background (#F4EFE4, matching C.bg), foreground and monochrome layers both at 16% inset. The monochrome layer means Android 13+ themed icons work. Not a template file. No finding.
.gitignore (root) and android/.gitignore read in full, checked for secret leakage ahead of Phase 4.7 release signing Root .gitignore is stock template and correct. Signing secrets are already covered: android/.gitignore:12-14 ignores key.properties, **/*.keystore, **/*.jks. No finding.
ios/Runner/SceneDelegate.swift read in full, cross-checked against the Xcode project and Info.plist Stock template and correctly wired: class SceneDelegate: FlutterSceneDelegate matches Info.plist UISceneDelegateClassName = $(PRODUCT_MODULE_NAME).SceneDelegate, and the file is in the Sources build phase (project.pbxproj:14,52,125,286). An empty subclass is the intended shape. No finding.
ios/Runner/Base.lproj/Main.storyboard read in full Stock template, FlutterViewController as initial view controller, referenced from Info.plist UIMainStoryboardFile and UISceneStoryboardFile. Correct. Its white view background is folded into F9.
pubspec.yaml / pubspec.lock read in full (no Phase-1 stream line names them; claimed under the S14 catch-all) version: 0.4.12+18 (:5) matches kAppVersion — proven by the mutation above. 10 direct runtime dependencies, 5 dev, all caret-pinned with a committed pubspec.lock (842 lines). Asset directories assets/audio/ and assets/logo/ declared (:44-46); assets/icon/ deliberately not bundled — it feeds flutter_launcher_icons only (:33-39). Nothing in this file is an S14 defect; dependency currency and licence obligations belong to S9 and S11.

git ls-files reconciliation — proof the audit partition is total

144 tracked files at 03a176e. Every one is mapped to exactly one owning stream; UNOWNED = 0. Machine-readable per-file mapping: proof/01_findings/S14/partition_s14.txt (144 lines, one <stream> <path> pair each). Stream scopes are as written in CHECKLIST.md Phase 1.

Stream Scope line in CHECKLIST.md Files What they are
S1 Timer engine correctness 2 lib/engine/engine.dart, lib/engine/models.dart
S2 Persistence, journal, diagnostics 3 lib/engine/store.dart, lib/journal.dart, lib/diagnostics.dart
S3 Audio, voice, backstop, native bridges 6 lib/audio/ ×3, lib/alarm_backstop.dart, MainActivity.kt, AppDelegate.swift
S4 UI layer 7 lib/ui/ ×7
S7 Test-suite quality + mutation checks 13 test/ ×13
S8 i18n and localization 1 lib/i18n.dart
S9 Platform config 23 android/ build and manifest files ×9 (build.gradle.kts ×2, 3 manifests, gradle.properties, gradle-wrapper.properties, settings.gradle.kts, android/.gitignore), ios/ project and config ×14 (Runner.xcodeproj/**, Runner.xcworkspace/**, Flutter/*.xcconfig, AppFrameworkInfo.plist, Info.plist, Runner-Bridging-Header.h, ios/.gitignore)
S11 Asset licensing & IP provenance 42 assets/ ×26 (15 WAV, 7 TTF, 3 icon, 1 logo), ios/.../AppIcon.appiconset/ ×16
S14 App entry + all otherwise-unowned files 47 listed in full below
S5, S6, S10, S12, S13 Error-handling discipline; DRY/dead code; product review; resource lifecycle; data inventory 0 exclusive Cross-cutting streams by design — they read files other streams own and file findings against them. They add no file to the partition and remove none.
UNOWNED 0
144 matches git ls-files \| wc -l exactly

The 47 files S14 owns

Group Count Files
App entry and root config 5 lib/main.dart, analysis_options.yaml, .gitignore, .metadata, README.md
Residual — named by no stream scope line 2 pubspec.yaml, pubspec.lock
android/app/src/main/res/** 23 values/styles.xml, values-night/styles.xml, values/colors.xml, drawable/launch_background.xml, drawable-v21/launch_background.xml, mipmap-anydpi-v26/ic_launcher.xml, raw/keep.xml, raw/cadence_alarm.wav, 5 × mipmap-*/ic_launcher.png, 5 × drawable-*/ic_launcher_foreground.png, 5 × drawable-*/ic_launcher_monochrome.png
iOS launch, scene and tests 9 Base.lproj/LaunchScreen.storyboard, Base.lproj/Main.storyboard, Runner/SceneDelegate.swift, RunnerTests/RunnerTests.swift, LaunchImage.imageset/ ×5 (Contents.json, 3 PNG, README.md)
web/ 7 index.html, manifest.json, favicon.png, icons/ ×4
tools/ 1 build_ringtones.py

Two boundary calls, stated so a refuter can contest them rather than guess: android/.../res/raw/cadence_alarm.wav sits inside android/.../res/** so S14 owns it structurally (shrinker pinning, resource wiring) while S11 owns its licence provenance and S3 owns its playback path; LaunchImage.imageset/** is claimed by S14 because it is the iOS launch screen, which is S14 scope through Base.lproj/LaunchScreen.storyboard, rather than by S11 as a generic asset.

Two files exist in the working tree but are not in git ls-files and are therefore outside the partition by construction: none found — git status --porcelain on cadence-app is clean at 03a176e and every file present is tracked.


Coverage manifest — every file in S14 scope

wc -l line counts. "Binary" files were inspected by SHA-256 comparison against a freshly generated Flutter template and, for PNGs, by reading the IHDR header for dimensions.

# File Lines What was checked
1 lib/main.dart 58 Read in full. Startup ordering traced call by call; Store.open() throw path proven by test T1; hang path established by absence of .timeout; global-error-handler absence proven by grep; wakelock .catchError traced to the banner and proven reachable by test T3; MediaQuery.withNoTextScaling scope proven by test T4; immersiveSticky call site and re-assertion absence established; kAppVersion drift proven caught by two-direction mutation; child! force-unwrap at :48 noted.
2 analysis_options.yaml 28 Read in full. Confirmed byte-identical to template; enumerated the 10 rules flutter_lints 6.0.0 actually enables; measured 6 candidate additions against the real codebase (75 issues, 2 in main.dart); found and excluded the always_use_package_imports / prefer_relative_imports incompatibility.
3 README.md 38 Read in full. Every factual claim checked against the code: background alarms, TTS voice scoring, zones, key count, test count, last-update date.
4 .metadata 30 Read in full and diffed against a fresh template. Platform migration list found to hold root + web only.
5 .gitignore 45 Read in full. Confirmed stock; checked for signing-secret coverage; found it in android/.gitignore:12-14.
6 pubspec.yaml 68 Read in full. Version, 15 dependencies, launcher-icon config, asset dirs, 3 font families / 7 TTF.
7 pubspec.lock 842 Inspected for the shared_preferences family and dependency pinning; not line-by-line audited (dependency currency is S9).
8 tools/build_ringtones.py 238 Read in full. Executed in a scratch copy; all 13 generated files verified byte-identical by SHA-256; judged against research/02_framework_routing.md §5a; path resolution from __file__ verified.
9 android/.../res/values/styles.xml 18 Read in full. LaunchTheme / NormalTheme parents and windowBackground traced. Stock.
10 android/.../res/values-night/styles.xml 18 Read in full. Dark-mode parents traced (Theme.Black.NoTitleBar). Stock.
11 android/.../res/values/colors.xml 3 Read in full. ic_launcher_background #F4EFE4 confirmed to match C.bg. Not a template file.
12 android/.../res/drawable/launch_background.xml 12 Read in full. @android:color/white. Stock.
13 android/.../res/drawable-v21/launch_background.xml 12 Read in full. ?android:colorBackground. Stock.
14 android/.../res/mipmap-anydpi-v26/ic_launcher.xml 14 Read in full. Adaptive icon with background, foreground, monochrome layers at 16% inset. Correct.
15 android/.../res/raw/keep.xml 6 Read in full. Shrinker pin for @raw/cadence_alarm with its incident note. Correct.
16 android/.../res/raw/cadence_alarm.wav binary Regenerated from tools/build_ringtones.py:236 and verified byte-identical (SHA-256 f32956f6…).
17-21 android/.../res/mipmap-*/ic_launcher.png ×5 binary SHA-256 compared to template — all CUSTOMISED, i.e. Serge's artwork, not the Flutter default. Provenance is S11.
22-26 android/.../res/drawable-*/ic_launcher_foreground.png ×5 binary Present at all five densities; consumed by mipmap-anydpi-v26/ic_launcher.xml. Not template files.
27-31 android/.../res/drawable-*/ic_launcher_monochrome.png ×5 binary Present at all five densities; consumed by the <monochrome> layer. Not template files.
32 ios/Runner/SceneDelegate.swift 6 Read in full. Cross-checked against Info.plist scene manifest and project.pbxproj. Correct.
33 ios/RunnerTests/RunnerTests.swift 12 Read in full. Empty assertion-free test in a wired target — finding F11.
34 ios/Runner/Base.lproj/LaunchScreen.storyboard 37 Read in full. White background, LaunchImage centred, declared 168×185 vs the 1×1 asset on disk.
35 ios/Runner/Base.lproj/Main.storyboard 26 Read in full. FlutterViewController initial view controller. Stock and correct.
36 ios/.../LaunchImage.imageset/Contents.json SHA-256 compared to template: stock.
37-39 ios/.../LaunchImage.imageset/LaunchImage{,@2x,@3x}.png binary All three byte-identical to each other and to the template; 68 bytes, 1×1 pixels (IHDR read).
40 ios/.../LaunchImage.imageset/README.md 4 Read in full. Stock template instructions.
41 web/index.html 46 Read in full. A new Flutter project. description, lowercase title. Byte-identical to template.
42 web/manifest.json 35 Read in full. Stock name/description, #0175C2, portrait-primary. Byte-identical to template.
43 web/favicon.png binary SHA-256 identical to template; 16×16.
44-47 web/icons/Icon-{192,512,maskable-192,maskable-512}.png binary All four SHA-256 identical to template (Flutter logo).

Proof index

All under proof/01_findings/S14/.

File What it proves
s14_main_test.dart The four probe tests written for this stream (T1–T4).
probe_main_green.txt flutter test run of all four, 00:00 +4: All tests passed!, EXIT_CODE=0, GIT_HEAD: 03a176e….
version_test_green_before.txt test/version_test.dart green on the pristine copy.
version_test_red_mutated.txt Same test RED after kAppVersion 0.4.120.4.13.
version_test_red_pubspec_bumped.txt Same test RED after pubspec.yaml 0.4.12+180.4.13+19 — the other drift direction.
version_test_green_after.txt Green again after restore, proving the copy is clean.
version_mutation.patch Both mutation diffs, saved per R8.
analysis_options_candidate.yaml The candidate lint set measured in F5.
analyze_candidate_lints.txt flutter analyze with that set: 75 issues, per-rule counts, lib/main.dart:30 and :33 named.
stock_template_comparison.txt SHA-256 comparison of 23 S14 files against flutter create on Flutter 3.44.8.
metadata_vs_template.diff The missing android and ios platform blocks in .metadata.
ringtones_regen.txt The generator run, 13 wrote … lines, EXIT_CODE=0.
ringtones_committed.sha256 / ringtones_regenerated.sha256 / ringtones_byte_identity.txt Byte-identity of the regenerated WAVs to the committed ones (empty diff, EXIT_CODE=0).
partition_s14.txt All 144 tracked files with their owning stream; zero UNOWNED.
S14 REFUTER — app entry, startup ordering, and the files S14 claimedagent_reports/S14_refute.md · raw .md

S14 REFUTER — app entry, startup ordering, and the files S14 claimed

Subject: the app repository at 03a176e72ef0075eec86b8915cbe6e93042a3b9d Governing rule: R5. Default is REFUTED under uncertainty. Work surface: a scratch working copy (a cp -R of the subject, mutated freely) and a scratch working copy (a fresh flutter create --project-name cadence --platforms=android,ios,web on the same Flutter 3.44.8). Nothing under cadence-app was written to — every recorded run against it stamps TREE_STATE: CLEAN at 03a176e…. Proof directory: proof/01_findings/S14_refute/ (23 files).

Verdict: 11 of 11 S14 findings CONFIRMED on substance. 1 structural claim REFUTED — the partition. 3 findings contributed that S14 missed. 2 minor factual errors corrected.


1. Score

S14 claim Verdict Strength of my check vs S14's
F1 blank window when Store.open() throws (HIGH) CONFIRMED T1 re-run green; realism established from the Android plugin's Kotlin source; window background verified against the Flutter engine's own FlutterActivity.java
F2 hang path, no .timeout anywhere on boot (HIGH) CONFIRMED S14 argued from absence. I executed it: flutter test killed at 90 s wall clock, EXIT_CODE=124
F3 no global error handler CONFIRMED grep re-run, recorded
F4 wakelock banner EXISTS, cannot clear, never re-asserted CONFIRMED S14 proved the banner with a hand-made Diag.fail. I drove the real main() with a failing wakelock backend, and proved both residual defects by test rather than grep
F5 analysis_options.yaml is stock; 6 rules → 75 issues CONFIRMED, exactly 75 reproduced twice — with S14's saved candidate file and with only the six named rules
F6 README.md stale and contradicts the code CONFIRMED (owner is S10) git log, key/test counts re-run
F7 web/ is 7 stock files, delete it CONFIRMED (owner is S6) 7/7 byte-identical to my own fresh template; zero references; after deletion flutter analyze = 0, suite = 123/123, flutter build apk --release = exit 0
F8 .metadata registers web but not android/ios CONFIRMED (owner is S9) diffed against my fresh template
F9 launch screens are stock on both platforms CONFIRMED (owner is S9) all six files STOCK-TEMPLATE; the three iOS launch PNGs share one SHA-256, 68 bytes each
F10 immersiveSticky set once, no kiosk lock CONFIRMED (owner is S9/S10 for the manifest half) greps re-run, recorded
F11 RunnerTests.swift asserts nothing CONFIRMED (owner is S7) file is STOCK-TEMPLATE; 19 RunnerTests references in project.pbxproj
test/version_test.dart genuinely catches drift CONFIRMED at full R8 S14 ran single-file. I ran the whole suite under --reporter=json for both directions
"144 files map to exactly one stream; S14 absorbs 47 including pubspec.yaml/pubspec.lock, which no CHECKLIST.md Phase-1 scope line names" REFUTED contradicted by PLAN.md:175, CHECKLIST.md:67, tools/check_partition.py and research/04_partition.md

2. Claim-by-claim

2.1 The blank-window claim (F1) — CONFIRMED, and reachable without mocking

T1–T4 re-run verbatim on my copy: 00:00 +4: All tests passed!, EXIT_CODE=0, GIT_HEAD: 03a176e…proof/01_findings/S14_refute/rerun_T1_T4.txt.

Is a throwing SharedPreferences.getInstance() only a mock artefact? No, on three independent grounds.

(a) The plugin's own Dart code is written to propagate it. shared_preferences 2.5.5, ~/.pub-cache/hosted/pub.dev/shared_preferences-2.5.5/lib/src/shared_preferences_legacy.dart:79-93 (that version is the one locked in pubspec.lock):

      try {
        final Map<String, Object> preferencesMap =
            await _getSharedPreferencesMap();
        completer.complete(SharedPreferences._(preferencesMap));
      } catch (e) {
        // If there's an error, explicitly return the future with an error.
        // then set the completer to null so we can retry.
        completer.completeError(e);

A failing getInstance() is a supported outcome of the API, not an impossible state.

(b) The Android implementation has unchecked throw sites on the read path. shared_preferences_android 2.4.27, android/src/main/kotlin/io/flutter/plugins/sharedpreferences/LegacySharedPreferencesPlugin.kt. getAllgetAllPrefstransformPref runs on every stored key at boot:

      } else if (value.startsWith(DOUBLE_PREFIX)) {
        val doubleStr: String = value.substring(DOUBLE_PREFIX.length)
        return doubleStr.toDouble()

and, for the legacy list encoding:

      } catch (e: IOException) {
        throw RuntimeException(e)
      } catch (e: ClassNotFoundException) {
        throw RuntimeException(e)
      }

Kotlin's String.toDouble() throws NumberFormatException on a malformed payload, and every handler is wrapped by pigeon (Messages.g.kt:114-115, MessagesPigeonUtils.wrapError(exception)), which returns an error the Dart side decodes as PlatformException. This app stores a double: lib/engine/store.dart:171, prefs.setDouble(_kVol, AlarmVolume.sane(v)) under key cadence-vol (:24). One corrupted byte in that one value and the app never draws a frame.

(c) The precise mechanism differs from S14's wording and should be corrected in the report. S14 wrote "a corrupt or locked prefs XML". A corrupt XML file is not the reachable path — Android's own SharedPreferencesImpl recovers from an unparseable file by starting empty. The reachable path is a corrupted value inside a well-formed file, on a key carrying one of the plugin's sentinel prefixes. That is a narrower and more accurate statement of the same defect; severity HIGH stands.

Does Flutter show anything before runApp? No, and S14's NormalTheme claim is right for the right reason. The engine source shipped with this toolchain, Flutter:

  protected void onCreate(@Nullable Bundle savedInstanceState) {
    switchLaunchThemeForNormalTheme();

(:632-633) and

  private void switchLaunchThemeForNormalTheme() {
    try {
      Bundle metaData = getMetaData();
      if (metaData != null) {
        int normalThemeRID = metaData.getInt(NORMAL_THEME_META_DATA_KEY, -1);
        if (normalThemeRID != -1) {
          setTheme(normalThemeRID);

(:767-773). This app declares that meta-data (android/app/src/main/AndroidManifest.xml:35-38, io.flutter.embedding.android.NormalTheme@style/NormalTheme), so the theme is swapped in onCreate, before the Dart entrypoint runs. NormalTheme's windowBackground is ?android:colorBackground (android/app/src/main/res/values/styles.xml:15-17) under Theme.Light.NoTitleBar, and the same under Theme.Black.NoTitleBar in values-night/styles.xml:15-16 — white in light mode, black in dark mode.

The outcome does not depend on which of the two themes wins, and that is worth stating because the same engine file at :175-178 says a launch screen "will automatically persist for as long as it takes Flutter to initialize and render its first frame". If the launch theme persisted instead, the user would see @drawable/launch_background, which in this project is @android:color/white (drawable/launch_background.xml:4) or ?android:colorBackground on API 21 and above (drawable-v21/launch_background.xml:4). Both themes therefore resolve to the same plain platform background on any device this app supports, and Flutter's own Dart-drawn splash was deprecated in Flutter 2.5 (:175-177), so there is no third thing on screen. Nothing is rendered, nothing is written, and the operator sees a plain rectangle. F1 confirmed at HIGH.

2.2 The hang path (F2) — CONFIRMED, and upgraded from inference to a run

The grep is exact. .timeout( occurs once in all of lib/, at lib/audio/voice.dart:168, and lib/main.dart contains no try — its only error construct is the .catchError at :30 (proof/01_findings/S14_refute/verification_greps.txt).

S14 stopped at absence. I made the failure happen. proof/01_findings/S14_refute/s14r_hang_test.dart installs a handler on plugins.flutter.io/shared_preferences that accepts the call and never answers — a wedged platform thread, not an error — and then await app.main():

COMMAND:    timeout 90 flutter test test/s14r_hang_test.dart --reporter=expanded
GIT_HEAD:   03a176e72ef0075eec86b8915cbe6e93042a3b9d
...
01:27 +0: S14-F2 main() never returns when the prefs channel goes silent - did not complete [E]
EXIT_CODE=124

(proof/01_findings/S14_refute/hang_path_timeout.txt. Exit 124 is the external timeout killing the run — the Dart test framework never reclaimed it either.)

Is a silent channel real on Android, or theoretical? Real, and I hit it by accident. While building the wakelock probes I called the real main() in a widget test without stubbing path_provider. Journal.init (lib/journal.dart:69) awaits getApplicationDocumentsDirectory(), that call never returned, and flutter test sat for the full 600-second tool budget with no output and no error. Not a designed experiment — an unplanned reproduction of exactly the failure mode F2 describes. The concrete Android circumstance: every one of these plugin methods executes on the platform (main) thread, and SharedPreferences.getAll blocks that thread until the preferences file has finished loading from disk. On a tablet whose storage is stalled — the eMMC on a Lenovo TB-8505F under a concurrent write, a filesystem in recovery after an unclean power-off, which is the normal way a kitchen tablet is switched off — that wait has no ceiling. Journal.init adds f.exists(), possibly f.create(), f.length(), prefs.setBool, prefs.setInt and a disk _flush() on the same thread before the first frame. No input is pending on a wall-mounted board, so Android raises no "app not responding" dialogue: the app simply never starts. F2 confirmed at HIGH.

2.3 The wakelock banner (F4) — CONFIRMED, and this is the one I tried hardest to break

A false "it works" is the expensive error here, so I did not accept S14's T3. T3 pumps CadenceApp and then calls Diag.fail('wakelock', …) by hand. That proves lib/ui/home.dart:683-684 maps the scope to a banner; it does not prove that lib/main.dart:30-32 ever produces that scope.

proof/01_findings/S14_refute/s14r_refute_test.dart closes the gap by replacing the wakelock backend rather than the Diag call, and running the real main():

class _FailingWakelock extends WakelockPlusPlatformInterface {
  @override
  Future<void> toggle({required bool enable}) async {
    throw PlatformException(code: 'wakelock_refused', message: 'simulated S14R');
  }
    wakelockPlusPlatformInstance = _FailingWakelock();
    await app.main();
    await tester.pump();
    await tester.pump();
    expect(find.byType(HomeScreen), findsOneWidget, ...);
    expect(Diag.critical.value, contains('wakelock'), ...);
    expect(find.textContaining('Keep-awake unavailable'), findsOneWidget, ...);

Recorded green — proof/01_findings/S14_refute/wakelock_end_to_end.txt, 00:00 +3: All tests passed!, EXIT_CODE=0, with the matching [cadence] wakelock: PlatformException(wakelock_refused, simulated S14R, null, null) in the output. Three further facts fall out: a failing wakelock does not stop the app booting (HomeScreen is found), the scope really is 'wakelock', and the banner really is mounted at lib/ui/home.dart:565, directly under the header. The banner works. S14's refutation of the earlier suspicion is correct.

Both residual defects are also confirmed, and I proved them by execution where S14 used grep:

  • (a) It can never clear. Test R2 raises the banner, then boots with a working wakelock, and the banner is still on screen: expect(find.textContaining('Keep-awake unavailable'), findsOneWidget) passes after the success path has run. lib/main.dart:28-29 logs to the journal and does nothing else. Recorded green in the same file. Grep agrees: 6 Diag.clearCritical call sites in lib/ (alarm_backstop.dart:91,202,203, audio/audio.dart:74, voice.dart:57, engine/store.dart:139) and none is the wakelock.
  • (b) Never re-asserted, never verified. Test R3 counts calls into the wakelock backend across a full inactive → paused → resumed cycle: toggleCalls stays at 1 and enabledReads stays at 0. WakelockPlus appears exactly once in all of lib/, at lib/main.dart:28.

Correction to S14's prose: it writes "grep for clearCritical in lib/ returns five call sites" and then lists six. The count is 6 (verification_greps.txt). The conclusion is unaffected.

2.4 test/version_test.dart — CONFIRMED at a standard S14 did not reach

S14's evidence was single-file runs. R8 requires the whole suite under --reporter=json with the failing set equal to exactly the named test, a "failure" rather than a load-time "error", a distinct patch per direction, and an empty git status --porcelain after revert. All four, both directions:

Run Non-hidden tests Non-success Which
baseline (suite_baseline_json.txt) 123 0
mutation A, kAppVersion '0.4.12''0.4.13' (suite_mutationA_json.txt) 123 1 result='failure', 'kAppVersion suit la version declaree dans pubspec.yaml'
mutation B, pubspec.yaml 0.4.12+180.4.13+19 (suite_mutationB_json.txt) 123 1 result='failure', same test

Patches saved distinctly as mutationA_kappversion.patch and mutationB_pubspec.patch. Tree state after each revert printed empty (REVERT_A_STATUS=[], REVERT_B_STATUS=[]), and the copy's final state is recorded clean in copy_clean_after_experiments.txt. The test that guards an incident which already happened twice is real in both directions.

2.5 analysis_options.yaml and the 75 issues — CONFIRMED exactly

analysis_options.yaml is STOCK-TEMPLATE against my own fresh flutter create (template_comparison_independent.txt). I reproduced the count twice, on a clean copy, with no source change:

Run Rules enabled Issues
analyze_candidate21_rerun.txt — S14's saved analysis_options_candidate.yaml 21 75
analyze_six_rules.txt — only the six S14 names 6 75

Identical per-rule split in both: discarded_futures 35, avoid_catches_without_on_clauses 30, unawaited_futures 4, avoid_dynamic_calls 4, avoid_slow_async_io 1, prefer_final_locals 1. The two main.dart hits are verbatim present:

info • Missing an 'await' for the 'Future' computed by this expression. … • lib/main.dart:30:6 • unawaited_futures
info • Missing an 'await' for the 'Future' computed by this expression. … • lib/main.dart:33:16 • unawaited_futures

The always_use_package_imports exclusion is also correct — enabling it alongside prefer_relative_imports produces warning • The rule 'prefer_relative_imports' is incompatible with ''always_use_package_imports'' at analysis_options.yaml:6:7 • incompatible_lint.

One imprecision worth fixing in the report: S14 says "turning on six well-chosen rules produces 75 issues", but its saved candidate file enables 21. The other fifteen fire zero times, so the number is right and the sentence is wrong. Phase 4 should adopt the six that actually bite, not copy the 21-rule file.

Which of the six to adopt — my judgement for a commercial product, since Phase 4 acts on this:

Rule Hits Adopt? Why
unawaited_futures 4 Yes, as an error Four sites, two of them on the boot path. lib/main.dart:33 drops the Future from SystemChrome.setEnabledSystemUIMode, so immersive mode is not guaranteed applied before the first frame. Cheapest real win in the set.
avoid_dynamic_calls 4 Yes All four are lib/audio/voice.dart:112,113,124,127, untyped reads of the native voice list — the exact shape that fails on one manufacturer's TTS engine and nowhere else.
avoid_slow_async_io 1 Yes lib/journal.dart:71, an async dart:io call on the boot path. It is the same line F2 is about.
prefer_final_locals 1 Yes One site, mechanical.
discarded_futures 35 No, not as a gate It fires on every deliberate fire-and-forget call in store.dart, voice.dart, alarm_backstop.dart, tile.dart. Most are intentional and correct in a UI callback; 35 forced unawaited(...) wrappers is churn that hides the four unawaited_futures hits that matter. Adopt only if the team also commits to reading each site.
avoid_catches_without_on_clauses 30 No This product's stated design is that no failure may take the app down; broad catch (e) into Diag.fail is the deliberate pattern, and 30 on Exception annotations would narrow catches that are meant to be broad. S5 owns the error-handling verdict; the lint would pre-empt it in the wrong direction.

--fatal-infos --fatal-warnings (gates G1/G2) can stay green on the four "Yes" rules after eight edits. It cannot on the two "No" rules without 65 edits.

2.6 web/ — CONFIRMED, delete it, and nothing in the build objects

Byte-identity verified against my own fresh template, not S14's: all seven files STOCK-TEMPLATE (template_comparison_independent.txt). Zero references — grep -rniE "flutter build web|kIsWeb|dart:html|package:web" lib/ test/ tools/ android/ ios/ README.md pubspec.yaml exits 1 with no output.

Consequence, measured rather than asserted. I deleted web/ on the copy and ran the three gates:

Gate Result Proof
flutter analyze --no-pub No issues found!, EXIT_CODE=0 web_deleted_analyze.txt
flutter test --reporter=json 123 non-hidden tests, 0 non-success web_deleted_test.txt
flutter build apk --release ✓ Built build/app/outputs/flutter-apk/app-release.apk (53.6MB), EXIT_CODE=0 web_deleted_apk_build.txt

CI: there is none. git ls-files at 03a176e contains exactly two YAML files, analysis_options.yaml and pubspec.yaml — no .github/, no workflow, nothing that references a web target.

.metadata is the one real coupling, and S14 identified it correctly. Its migration: platforms: list holds root and web and nothing else, against root, android, ios, web in my fresh template. Deleting web/ without deleting the - platform: web block leaves flutter migrate pointing at a directory that no longer exists. Both edits go together.

2.7 The partition — REFUTED

S14 states that all 144 tracked files map to exactly one stream, that it owns 47 of them, and that pubspec.yaml / pubspec.lock are "named by no CHECKLIST.md Phase-1 scope line" and therefore fall to an S14 catch-all. Every part of that is contradicted by the governing documents. S14 is auditing against a scope that was deleted before Phase 1 launched.

PLAN.md:175, verbatim:

| S14 | App entry and startup ordering — no catch-all clause (deleted in v4; it absorbed 37 files it had no remit to audit) — lib/main.dart (58 lines, 0.00% covered, holds the wakelock failure path and the startup sequence), ios/Runner/SceneDelegate.swift, analysis_options.yaml (S7 secondary). Its former residual files are assigned by name in research/04_partition.md |

CHECKLIST.md:67, verbatim:

| S14 | App entry and startup ordering — no catch-all. P: lib/main.dart, ios/Runner/SceneDelegate.swift, analysis_options.yaml (S7 secondary) | … |

CHECKLIST.md:50: "S14 has no catch-all clause." tools/check_partition.py, the file that calls itself "the SINGLE SOURCE OF TRUTH for who audits what", carries the same three claims under # --- S14 · app entry and startup ordering (named files only — no catch-all) --- and states in its header comment that "S14 has no catch-all clause here, by design".

I re-ran it: ✅ PASS — partition is total, 144 tracked, 67 owned, 77 multi-owned, 0 unowned (check_partition_rerun.txt). Diffing its per-file primaries against S14's own partition_s14.txt (partition_ownership_diff.txt):

canonical primaries : 144
S14 partition rows  : 144
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}

THE THREE FILES CANONICALLY OWNED BY S14:
  analysis_options.yaml
  ios/Runner/SceneDelegate.swift
  lib/main.dart

pubspec.yaml is owned by S6, with S9 secondary. tools/check_partition.py:

    Claim("pubspec.yaml", "S6", "declares which packages and which asset directories actually ship"),
    Claim("pubspec.yaml", "S9", "carries version 0.4.12+18, the build number both stores order releases by"),
    Arbitration("pubspec.yaml", "S6", ("S9",),
        "S9 reads only the version/build-number line for store release ordering; dependencies and asset declarations are S6's."),

research/04_partition.md:46 says the same in prose, and :47 gives pubspec.lock to S6 outright. S14's sentence "no CHECKLIST.md Phase-1 scope line names them" is false: CHECKLIST.md's S6 line does. The discrepancy is not between two defensible readings — it is S14 working from a superseded version of its own brief.

What this costs. Five of S14's eleven findings sit entirely outside its scope: F6 (README.md → S10), F7 (web/ → S6), F8 (.metadata → S9), F9 (launch screens → S9), F11 (RunnerTests.swift → S7). Their substance is sound — I verified all five — but the coordinator must reconcile them against those streams' own findings before Phase 4, or the same defect gets fixed twice or, worse, gets counted as covered when the owning stream skipped it believing S14 had it. That reconciliation is a real, unbudgeted Phase-2 task and it is the finding below.


3. Findings S14 missed (R5 contribution)

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

  • Severity: MEDIUM
  • Location: analysis_options.yaml:1-29 (valid at 03a176e) — the absence of an analyzer: block is the finding; the errors it hides include lib/main.dart:31
  • What is wrong: S14-F5 measured one dimension of this file — which entries sit under linter: rules: — and concluded from 75 lint infos that the analyser is "switched almost off". It never touched the other dimension. The Dart analyser's analyzer: language: block controls strict-casts, strict-inference and strict-raw-types, which are not lints at all: they change what the type system will accept, and their findings are severity error, not info. This project sets none of them, so implicit dynamic → T downcasts compile silently. That is a strictly larger hole than the six lint rules, and it is invisible in the baseline fact "flutter analyze → 0 issues" for the same reason.
  • Evidence: 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. - Why it matters for a restaurant kitchen: this is the one gate that runs on every change with no human in the loop, and the product's persisted state is JSON decoded through untyped maps. A cast that fails at 18:30 loses the board's timers; a cast the analyser rejects at commit time costs nothing. The five catchError sites are lower risk (a Dart error is never null) but they are the reason e is untyped throughout the error-handling layer S5 is auditing. - Proposed fix: 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 the fix: 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-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

  • Severity: MEDIUM
  • Location: lib/main.dart:43-56 (valid at 03a176e)
  • What is wrong: the MaterialApp constructed at lib/main.dart:43 passes title, debugShowCheckedModeBanner, builder, theme and home. It passes no localizationsDelegates, no supportedLocales and no locale, and flutter_localizations is not a dependency (absent from pubspec.yaml and pubspec.lock). Flutter therefore installs DefaultMaterialLocalizations, whose locale is fixed at en_US. The app's own chrome does translate — lib/i18n.dart is a full FR/EN table wired through I18n(widget.store.lang) at lib/ui/home.dart:84 — but everything Material supplies does not. The product has four text-entry fields (lib/ui/modals.dart:249, :334, :478, :529); their long-press selection toolbar reads "Cut / Copy / Paste / Select all" in English on a tablet a French kitchen set to French. S8 owns lib/i18n.dart; the missing constructor arguments are in lib/main.dart, which is S14's file, and S14 read the MaterialApp closely enough to write finding T4 about the builder on the very next line.
  • Evidence: 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) - **Why it matters for a restaurant kitchen:** the product is sold to French restaurants and its headline setting is the FR/EN switch. A cook renaming a timer mid-service long-presses the field and gets an English menu inside a French app. It is small, it is on the one screen where the operator types, and it is the kind of seam a buyer's technical reviewer notices in the first two minutes. It is also a store-listing consistency issue: a listing in French for an app whose system menus answer in English. - **Proposed fix:** 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.langis 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 the fix:** invert the test above.expect(ml, isA())andexpect(ml.pasteButtonLabel, 'Paste')must becomeexpect(ml.pasteButtonLabel, 'Coller')with the store set to'fr'. That assertion fails at03a176e— proven above, the label is'Paste'` — and passes after.

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

  • Severity: MEDIUM (process defect; it changes what Phase 2 and Phase 4 must do, not what the app does)
  • Location: 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 (S14 claim block and ARBITRATION), research/04_partition.md:46-47
  • What is wrong: S14's file is titled "App entry, startup ordering, and every file no other stream owns" and declares a scope of "everything in git ls-files that S1–S13 do not cover". No such clause exists. PLAN.md:175 records that it was "deleted in v4" precisely because "it absorbed 37 files it had no remit to audit", and both CHECKLIST.md and the partition tool carry the replacement: three named files. S14 then published a reconciliation table asserting its own 47-file ownership as fact and inviting a refuter to contest only two boundary calls (cadence_alarm.wav and LaunchImage.imageset/**) — when 44 of the 47 are contested, including those two.
  • Evidence: 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"),
  • Why it matters for a restaurant kitchen: indirectly but concretely. The partition exists so that no file is audited by nobody. If the coordinator accepts S14's table, S6, S9, S10 and S11 look covered on 44 files they may have skipped, and a real defect in one of them — Play rejecting an incomplete launcher density set, an unlicensed font, a stale README linked from the store listing — reaches submission with two documents disagreeing about who was supposed to look.
  • Proposed fix: (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 the fix: 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.

4. Coverage manifest — every file in S14's canonical scope, plus every file it claimed

4.1 S14's real scope (tools/check_partition.py), read in full by me

# File Lines What I checked, independently of S14
1 lib/main.dart 58 Read in full. Re-ran T1/T2/T3/T4. Traced Store.open()SharedPreferences.getInstance() into the plugin's Dart source and into the Android Kotlin handler to settle whether the throw is real. Executed the hang path to EXIT_CODE=124. Drove the real main() with a failing wakelock backend end to end to the rendered banner, and with a working one to prove the banner cannot clear. Counted backend calls across a full lifecycle cycle to prove no re-assertion. Verified the NormalTheme window claim against the Flutter engine's FlutterActivity.java. Confirmed no try, no .timeout, no global error handler. Read the MaterialApp at :43-56 argument by argument — found M2 there. Checked the child! at :48: MaterialApp with a non-null home always passes a non-null child to builder, so it is safe; not a finding. kAppVersion drift confirmed by two whole-suite JSON mutations.
2 analysis_options.yaml 28 Read in full. Byte-identity to a fresh flutter create confirmed with my own template. Reproduced 75 issues twice (21-rule and 6-rule configurations). Reproduced the incompatible_lint warning. Measured the dimension S14 did not — analyzer: language: strict modes, 13 issues, 6 of severity errorfound M1 there. Formed the adopt/reject judgement in §2.5 that Phase 4 needs.
3 ios/Runner/SceneDelegate.swift 6 Read in full. Confirmed STOCK-TEMPLATE against my own template. Cross-checked class SceneDelegate: FlutterSceneDelegate against ios/Runner/Info.plist:29-49, where UISceneDelegateClassName is $(PRODUCT_MODULE_NAME).SceneDelegate, UISceneConfigurationName is flutter and UISceneStoryboardFile is Main. Checked the registration side: ios/Runner/AppDelegate.swift adopts FlutterImplicitEngineDelegate and registers plugins in didInitializeImplicitFlutterEngine, which is the scene-lifecycle-correct hook — an empty FlutterSceneDelegate subclass is the intended shape and nothing is missing. S14's "no finding" is correct.

4.2 The 44 files S14 claimed that belong to other streams — substance checked, ownership corrected

Files S14 finding True owner My verdict on the substance
README.md F6 S10 Confirmed. git log --oneline -1 -- README.md22902e0, the first commit.
.metadata F8 S9 Confirmed. migration: platforms: = root, web; my fresh template has four blocks.
.gitignore (clean) S9 Confirmed STOCK-TEMPLATE.
pubspec.yaml, pubspec.lock (clean) S6 (S9 secondary on pubspec.yaml) S14's claim that no scope line names them is false — CHECKLIST.md's S6 line does.
web/ ×7 F7 S6 (S11 secondary on the icons) Confirmed: 7/7 STOCK-TEMPLATE; zero references; after deletion analyze 0, tests 123/123, release APK builds.
android/app/src/main/res/values/styles.xml, values-night/styles.xml, drawable/launch_background.xml, drawable-v21/launch_background.xml, mipmap-anydpi-v26/ic_launcher.xml, raw/keep.xml, values/colors.xml F9, clean S9 Confirmed: the four launch/theme files are STOCK-TEMPLATE; launch_background.xml is @android:color/white and the v21 variant ?android:colorBackground, verbatim. colors.xml and ic_launcher.xml are genuinely customised — S14's "no finding" holds.
android/.../res/mipmap-*/ic_launcher.png ×5, drawable-*/ic_launcher_foreground.png ×5, drawable-*/ic_launcher_monochrome.png ×5, raw/cadence_alarm.wav (clean) S11 (S9/S6/S3 secondary) Not re-derived; S14's structural read is compatible, but licence provenance is S11's call, not S14's.
ios/Runner/Base.lproj/LaunchScreen.storyboard, Main.storyboard, LaunchImage.imageset/** ×5 F9 S9 Confirmed: both storyboards and the imageset are STOCK-TEMPLATE; the three launch PNGs share one SHA-256 (93ae7d49…) and are 68 bytes each.
ios/RunnerTests/RunnerTests.swift F11 S7 Confirmed: STOCK-TEMPLATE, testExample() empty, 19 RunnerTests references in project.pbxproj. Cannot be executed here — no Xcode (baseline).
tools/build_ringtones.py (clean) S11 (S6 secondary) Not re-run. S14's byte-identity result is recorded in its own proof directory; regenerating 13 WAVs a second time adds nothing and re-checks a dimension already settled with file evidence.

5. Proof index — proof/01_findings/S14_refute/

File What it proves
rerun_T1_T4.txt S14's four probe tests re-run on my copy: +4: All tests passed!, EXIT_CODE=0
s14r_refute_test.dart / wakelock_end_to_end.txt R1/R2/R3 — the wakelock banner reached through the real main(); the banner cannot clear; no re-assertion on resume, enabled never read
s14r_hang_test.dart / hang_path_timeout.txt F2 executed — main() suspended for 90 s on a silent prefs channel, EXIT_CODE=124
s14r_l10n_test.dart / l10n_gap.txt M2 — French store language, DefaultMaterialLocalizations, en_US, 'Paste'
suite_baseline_json.txt, suite_mutationA_json.txt, suite_mutationB_json.txt R8 whole-suite JSON: 123/0, then exactly one "failure" per direction, named
mutationA_kappversion.patch, mutationB_pubspec.patch the two distinct mutation patches
analyze_candidate21_rerun.txt, analyze_six_rules.txt 75 issues reproduced twice, identical per-rule split, both main.dart hits present
analysis_options_strict_modes.yaml, analyze_strict_modes.txt M1 — 13 issues, 6 of severity error, one at lib/main.dart:31
template_comparison_independent.txt my own fresh-template SHA-256 comparison of 20 files
web_deleted_analyze.txt, web_deleted_test.txt, web_deleted_apk_build.txt F7's fix proven: analyze 0, tests 123/123, release APK exit 0 with web/ gone
check_partition_rerun.txt canonical partition re-run: PASS, 144 tracked, 0 unowned
partition_ownership_diff.txt, s14r_partition_diff.py M3 — 47 claimed vs 3 canonical, 44 mismatches, by true owner
verification_greps.txt F3, F9, F10, F11 greps re-run and stamped at 03a176e
copy_clean_after_experiments.txt R10 — the work copy restored, TREE_STATE: CLEAN