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:177and:182, clock sourceEngineHost.now()at:11 - What is wrong: every deadline is an absolute epoch-millisecond value (
endsAt = now + duration) andtick()fires purely onn >= 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), andproof/01_findings/S1/defects_run.txt, testsS1-F1andS1-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:286and:328 - What is wrong:
tick()wraps each run entry in atry/catch (_)whose stated purpose is to drop a structurally corrupt entry so it cannot freeze the board. But thetryalso encloses the two host callbacks,host.onAlarmFire(t)at:286andhost.onStepAdvance(...)at:328. A throw from either one is indistinguishable from corrupt data, so the handler deletes a perfectly healthy run entry — one that_fireAlarmhas already moved toRunStatus.ringingand 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 callshost.persistRun()at:342.host.persistRun()is also called from inside thetry, at:285(in_fireAlarm) and:327(chain advance). IfpersistRunis the thing that threw, the handler re-invokes it and the second throw is unguarded: it escapestick()entirely, past the remaining entries of theviewList()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:341before 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
stepslist of length 1,saveDefdegrades it to a single timer by discarding the list — and with it the only duration the caller supplied. The step'ssecis never transferred todurationSec. If the caller also passed nodurationSec(or one below the 5 s floor), the timer becomes a five-second timer; if it passed an unrelateddurationSecfrom 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 atlib/engine/models.dart:147-149 - What is wrong:
_nextBatchNofolds over the live clones only. BecausestopTimerdeletes a clone fromclones(: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) anddefects_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 ofhost.persistDefs(),host.persistRun()orhost.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 Stringhas no fallback, so a stored record missingidthrowsTypeError. The surrounding fields look defended —(j['name'] ?? 'Timer') as String— but the??only substitutes onnull; 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:
startTimercomputesn + t.durationSec * 1000with no upper bound anywhere in the chain.saveDef's floors are lower bounds only.TimerDef.fromJsonaccepts any 64-bit integer.adjustTimeraddsdeltaSec * 1000unchecked. When the product exceeds2^63-1the Dartintwraps 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:runningmust have anendsAt(:84) andpausedmust have aremainingMs(:85). It checks nothing forringing. Buttick()'s ringing branch requiresr.nextVoiceAt != null(:334) before it will repeat. A ringing entry restored without that field is accepted byreconcile(), 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:
alarmLeadMsis 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.patchsets the constant to0:
- 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:
reorderremoves the dragged definition and then inserts at the target's original index. After the removal every index abovefromhas 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 anddefects_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
:189is "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.startTimerat:198then 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, anddefects_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 endsAt → driftMs == 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 toJson → Map<String,dynamic>.from → fromJson 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. |