Close the SDD cycle for the data-loss shielding change: verified pass with warnings (0 critical, 10/10 scenarios with named tests, W1 fixed post-verify), three stacked work units shipped plus the path-resolution fix. The persistence-resilience capability spec is promoted to openspec/specs/.
27 KiB
Tasks: Persistence Corruption Guard
Change: persistence-corruption-guard Delivery strategy: stacked-to-main work-unit commits (chain decision already resolved by orchestrator — see forecast below) File: openspec/changes/persistence-corruption-guard/tasks.md
Review Workload Forecast
| Field | Value |
|---|---|
| Estimated changed lines | ~750-850 total (Phase A ~350-420, Phase B ~250-300, Phase C ~150-200) |
| 400-line budget risk | High (total); Phase A alone is borderline-High, B is Medium, C is Low-Medium |
| Chained PRs recommended | Yes |
| Suggested split | PR 1 (Alarms + shared helper) -> PR 2 (Stations) -> PR 3 (EQ) |
| Delivery strategy | stacked-to-main work-unit commits |
| Chain strategy | stacked-to-main |
Decision needed before apply: No Chained PRs recommended: Yes Chain strategy: stacked-to-main 400-line budget risk: High
The 3-work-unit split (Alarms -> Stations -> EQ) IS the chaining answer: each phase ships as its
own commit/PR, independently reviewable and independently shippable, gated by its own targeted
suite. No further user decision needed before sdd-apply starts.
Suggested Work Units
| Unit | Goal | Likely PR | Notes |
|---|---|---|---|
| 1 | Shared tolerant-parse helper + Alarms degraded guard (Phase A) | PR 1 | Base: main. Creates persistencia_tolerante.dart; highest severity (auto-wipe via recalcularTodas) |
| 2 | Custom-station quarantine + IO-degraded guard (Phase B) | PR 2 | Base: PR 1 branch (stacked-to-main). Depends on persistencia_tolerante.dart from PR 1 |
| 3 | EQ tolerant readers + logging (Phase C) | PR 3 | Base: PR 2 branch (stacked-to-main). Depends on persistencia_tolerante.dart from PR 1 |
If Phase A's actual diff exceeds ~450 lines, an optional 4th interim split (helper commit vs. alarms-consumption commit) is available but not required.
Stacked-to-main is the correct chain strategy here per the chained-pr skill's decision gate
("PR >400, each slice can land independently -> Stacked PRs to main") — each phase ships real
user-facing value alone. When opening each work-unit PR, sdd-apply should follow chained-pr's
Output Contract (dependency diagram with a current-PR marker, review budget, verification plan)
and work-unit-commits' checklist (tests travel with the behavior commit, as already reflected
in every RED/GREEN pairing below).
Phase A: Alarms (work unit 1 — worst blast radius, ships first) — COMPLETE (2026-07-11)
RED — shared tolerant-parse helper (D1)
- A1 Create
test/servicios/persistencia_tolerante_test.dart:parseListaToleranteskips 1 bad entry among 3 valid, returns the 3 survivors, logs the skip. (D1, Req: per-entry tolerant parsing, diagnostics) — done; plus a triangulation case (valid-shape entry whose parser itself throws) and a null-input case. - A2 Same file:
parseMapaToleranteskips 1 bad VALUE among 3 map entries, original keys preserved untouched. (D1, Req: per-entry tolerant parsing) — done. - A3 Same file: both helpers on all-bad input return empty survivors, no exception escapes, still log. (D1, Req: degraded-read precondition) — done.
- A4 Run
flutter test test/servicios/persistencia_tolerante_test.dart— confirm RED (helpers undefined). — confirmed: compile error,parseListaTolerante/parseMapaTolerantenot found.
GREEN — shared tolerant-parse helper
- A5 Create
lib/servicios/persistencia_tolerante.dart:parseListaTolerante<T>over an ALREADY-decodedList<dynamic>?(top-level string decode stays caller-owned — total-failure policy differs per subsystem, D3/D4 vs D5 vs D6); per-entry try/catch; calls the skip logger per skip. (D1) — done. - A6 Same file:
parseMapaTolerante<V>analogous forMap<String, dynamic>?, preserves original keys (needed later for EQ's colon-delimited matrix keys). (D1) — done. - A7 Same file:
registrarSaltoPersistencia({subsistema, detalle, razon})->debugPrint('[PluriWave][persistencia] ...'). No l10n string added. (Req: diagnostics are developer-facing only) — done. - A8 Re-run A1's test file — confirm GREEN. — confirmed: 5/5 pass.
RED — Alarms tolerant parse + degraded flag
- A9 Create
test/servicios/servicio_alarmas_corrupcion_test.dartwith a local_PrefsEspia(mirrorsservicio_alarmas_cache_test.dart's pattern, add anescriturasStringcounter). — done; also seeds via constructor + exposeslecturasString. - A10 Test A1 (no-thrash, explicit per orchestrator ask): seed
alarmas_musicales_v1with 2 valid + 1 malformed alarm entry;cargar()-> exactly 2alarmas; callrecalcularTodas()across 2 separate ticks;escriturasStringgrows by AT MOST 1 total (normalized cache stops the dirty-guard from re-firing every cycle). (D3, Req: per-entry tolerant parsing + healthy-behavior parity) — done; malformed entry = anidfield of the wrong type (42) soAlarmaMusical.fromJsonthrows — this is what makes the test genuinely RED pre-fix (a non-Map entry alone is already silently filtered by the old.whereType<Map>()and does not exercise the bug). - A11 Test A2 (total fail, zero writes): seed
'{bad';cargar()->alarmasempty;recalcularTodas()->escriturasStringunchanged. (D4, Req: degraded read suppresses automatic writes) — done; also asserts the raw string on disk is byte-identical to the original corrupt seed. - A12 Test A3 (explicit save restores authority): after A2's seed,
guardarAlarma(...)writes once and clears the flag; a following real-changerecalcularTodas()writes normally again. (D4, Req: degraded read — explicit mutation restores authority) — done. - A13 Test A4 (missing id never fabricated): seed 1 entry with no
idamong 2 valid;cargar()-> exactly 2 alarmas, none carrying a freshly generated id. (D2, Req: entry identity is never fabricated) — done. - A14 Run the new file — confirm RED. — confirmed: 3/4 new tests failed pre-fix (A10 got 0 survivors instead of 2; A11 got 1 spurious write instead of 0; A13 got 0 survivors instead of 2). A12 passed vacuously pre-fix since old code has no degraded flag to get stuck on — expected, not a rigor gap (its precondition setup is shared with A11, which independently proves the bug).
GREEN — Alarms implementation
- A15 Restructure
_parsear/_configActual(lib/servicios/servicio_alarmas.dart:59-110): top-leveljsonDecodefailure ->_lecturaAlarmasDegradada=true,_cache=empty config,_cacheRaw=rawunchanged (D4); success -> parsealarmas/vacaciones/excepcioneseach viaparseListaTolerante(D1); any skips ->_cache=survivors,_cacheRaw=_serializar(survivors)normalized (D3); zero skips -> unchanged behavior, clear the flag if previously set (Req: suppression lifts on next successful read). — done; kept the per-fielddata['x'] as List?casts INSIDE the same try asjsonDecode(same boundary as the original code) so a container-level field of the wrong shape also degrades cleanly instead of throwing uncaught. - A16 Keep
AlarmaMusical.fromJson'sid: json['id'] as Stringunchanged (lib/modelos/alarma_musical.dart:145-147); doc-comment only — the caller-side per-entry try (A15) is what enforces skip-never-fabricate. (D2) — done (doc comment only, zero functional change; included despite not being spelled out verbatim in the orchestrator's file-scope summary line, since it is explicit task A16 and design's own File Changes table). - A17 Add
bool _lecturaAlarmasDegradada = false;field; inrecalcularTodas()(line 201) addif (_lecturaAlarmasDegradada) return config;BEFORE the existing dirty-guard. (D4) — done. - A18 In
_guardar()(line 424) add_lecturaAlarmasDegradada = false;— every explicit mutation funnels through_guardar, restoring write authority. (D4, Req: explicit mutation restores authority) — done. - A19 Run
servicio_alarmas_corrupcion_test.dart— confirm GREEN (A1-A4 all pass). — confirmed: 4/4 pass.
REFACTOR + suite gate
- A20 Review naming/doc comments in
_parsear/_configActual/persistencia_tolerante.dart; no behavior change. — done during GREEN (doc comments written alongside the implementation); re-reviewed, no further changes needed. - A21 Targeted suite:
flutter test test/servicios/servicio_alarmas_cache_test.dart test/servicios/servicio_alarmas_proxima_test.dart test/servicios/servicio_alarmas_snooze_test.dart test/servicios/servicio_alarmas_corrupcion_test.dart test/servicios/persistencia_tolerante_test.dart— all green. NOTE:servicio_alarmas_android_test.dart,servicio_alarmas_android_instance_test.dart,servicio_alarmas_pre_notice_template_test.dartexerciseServicioAlarmasAndroid(native channel bridge, a different class untouched here) — confirmed via import check, excluded from this gate. (Req: healthy reads unchanged) — confirmed: 19/19 pass. - A22
flutter analyze --no-fatal-infoson the changed/created source files — clean. — confirmed: ran plainflutter analyze(stricter, no info-suppression) on all 5 changed/created files — "No issues found!". - A23 Commit as work unit 1: "Alarms — persistence corruption guard" (stacked-to-main, base: main). — done: commit
b66eec0fix(alarm): stop corrupt entries and unreadable payloads from wiping saved alarms(5 files changed, 514 insertions, 24 deletions — above the ~350-420 forecast; still a single self-contained, independently-revertable work unit, no further split performed). Note:openspec/changes/persistence-corruption-guard/left untracked/uncommitted, matching this repo's convention of separatedocs(openspec)commits.
Phase B: Custom Stations (work unit 2) — COMPLETE (2026-07-11)
RED
- B1 In
test/estado/estado_radio_test.dartadd a local_ArchivoEspia implements Filefake (mirrors_PrefsEspia's implements+noSuchMethodpattern) with overridableexists/readAsString/writeAsString/rename/path, to force an IO failure independent of the OS. — done; injectable overrides via constructor +noSuchMethodfallback for the rest ofFile's interface, plus awriteAsStringCallscounter used by the write-guard assertion. - B2 Test B1 (partial survivors, covers 2 requirements): temp file (raw-JSON helper, not
Emisora.toMap) with 2 valid station maps + 1 entry missing a required field + 1 entry missinguuid; afterinicializar(), exactly 2 stations survive, none carrying a freshly generated uuid. (D5 partial, Req: per-entry tolerant parsing + entry identity never fabricated) — done via a new_crearArchivoCustomRawhelper; confirms exactly{custom-1, custom-2}survive. - B3 Test B2 (total parse-fail -> quarantine): temp file content
'{bad'; after load,emisoras_custom.json.corruptexists holding the ORIGINAL bytes, live path absent/empty. (D5 parse-fail) — done. - B4 Test B3 (add after B2 restores authority): after B2's quarantine,
agregarEmisoraCustom(nueva)-> live file has ONLYnueva;.corruptsidecar untouched. (D5, Req: explicit mutation restores authority) — done. - B5 Test B4 (IO error suppresses writes, NOT lifted by explicit add):
_ArchivoEspiawithreadAsStringthrowing,exists()true; after load,agregarEmisoraCustom(...)-> the fake'swriteAsStringis NEVER invoked. NOTE (non-obvious, do not "fix"): unlike Alarms/EQ, station IO-fail suppression is intentionally NOT lifted by an explicit mutation this session — D5's rationale is avoiding clobbering a possibly-transiently-unreadable good file. (D5, Req: degraded read suppresses automatic writes) — done; also confirms the in-memory list still reflects the add (only the FILE write is suppressed, matchingagregarEmisoraCustom's existing reassign-then-persist structure). - B6 Test B5 (sidecar-already-exists edge case): temp dir pre-seeded with an existing
.corruptfile (content X); live file has a NEW parse failure; after load,.corruptstill holds X untouched, live path cleared. (D5 "else drop the live copy") — done. - B7 Run the file — confirm RED for the new/changed cases. — confirmed: 5/5 new tests failed pre-fix for the correct reasons (0 survivors instead of 2; sidecar missing;
PathNotFoundExceptionreading a sidecar that doesn't exist yet pre-quarantine; 1 spurious write instead of 0; live file not cleared when a sidecar already exists). All 13 pre-existing tests stayed green.
GREEN
- B8 Split
_cargarEmisorasCustom's catch (lib/estado/estado_radio.dart:503-521): readarchivo.readAsString()in its own try — IO exception ->_customDegradado=true,_emisorasCustom=[], log, do not touch the file. (D5 IO-fail, Req: diagnostics) — done via a new_leerContenidoCustom(File)helper:exists()+readAsString()share one try; any throw sets the flag, clears the in-memory list, logs, and returnsnull(file untouched). Judgment call:_archivoCustom()'s own resolution (building theFile/callinggetApplicationDocumentsDirectory()) is called BEFORE this try, matching the task's literal scope ("readarchivo.readAsString()in its own try"); the old code's single catch-all also covered that resolution step, so a hypothetical failure there is now uncaught. No test (old or new) exercises it and it is a stablepath_providercall in production — flagged here for reviewer awareness, not fixed, to avoid unrequested scope creep. - B9 Decode/parse the read string via
parseListaTolerante<Emisora>(D1); top-leveljsonDecode/as Listfailure -> rename live file to'${path}.corrupt'(only if no sidecar exists yet; else delete the live file per D5), clear live path,_emisorasCustom=[], log — NO flag set. — done inside_cargarEmisorasCustom's second try, delegating the rename/delete choice to a new_ponerEnCuarentena(File)helper. - B10 Per-entry skips with survivors ->
_emisorasCustom=survivors, no flag, no quarantine. (D5 partial) — done viaparseListaTolerante<Emisora>(data, Emisora.fromMap, subsistema: 'emisoras_custom', coleccion: 'emisoras_custom');_customDegradadoexplicitly cleared on this path (clean and partial reads both restore authority). - B11 Add
bool _customDegradado = false;field; guard the top of_guardarEmisorasCustom()(line 523) withif (_customDegradado) return;. (D5) — done. - B12 Clear
_customDegradado=falseonly inside_cargarEmisorasCustomon a clean/partial read — NOT insideagregarEmisoraCustom/eliminarEmisoraCustom(intentional asymmetry, see B5). (D5) — done; cleared in both the file-absent branch and the successful-parse branch; the parse-fail (quarantine) branch leaves the flag untouched (D5 says "NO flag set" — interpreted as neither set nor cleared, since the quarantine's cleared live path is itself the "fresh start" signal).agregarEmisoraCustom/eliminarEmisoraCustomunmodified. - B13 Run
test/estado/estado_radio_test.dart— confirm new cases GREEN and existing custom-station cases (e.g. "incluye emisoras custom en el listado principal de inicio") unchanged. — confirmed: 18/18 pass (13 pre-existing + 5 new), diagnostic[PluriWave][persistencia]lines observed for every corrupt/degraded scenario.
REFACTOR + suite gate
- B14 Review naming/doc comments; confirm
agregarEmitoraCustom/eliminarEmitoraCustomcompat aliases (lines 541-542, 551-552) still route through the guarded methods. — confirmed: both aliases (now ~622-624/632-634 after the new helpers were inserted) delegate unchanged toagregarEmisoraCustom/eliminarEmisoraCustom, which call the now-guarded_guardarEmisorasCustom();importarConfig's own direct_guardarEmisorasCustom()call is covered by the same single choke point (an explicit import while IO-degraded is also suppressed, consistent with D5's stated asymmetry). - B15 Targeted suite:
flutter test test/estado/estado_radio_test.dart— full file green (only Glob match forestado_radio*). (Req: healthy reads unchanged) — confirmed: 18/18 pass;Glob test/estado/estado_radio*returns only this one file. - B16
flutter analyze --no-fatal-infos— clean. — confirmed: ran plainflutter analyzeonlib/estado/estado_radio.dart+test/estado/estado_radio_test.dart— "No issues found!". - B17 Commit as work unit 2: "Custom stations — persistence corruption guard" (stacked-to-main, base: work-unit-1 branch). Depends on
persistencia_tolerante.dartfrom Phase A. — done: commitcbed0a6fix(radio): quarantine corrupt custom-station files instead of silently emptying them(2 files changed, 312 insertions, 10 deletions), stacked directly onmainon top of Phase A's65c1ac2. Not pushed — orchestrator pushes.openspec/changes/persistence-corruption-guard/left untracked/uncommitted, matching Phase A's convention of separatedocs(openspec)commits.
Phase C: Equalizer (work unit 3) — COMPLETE (2026-07-11)
RED
- C1 In
test/servicios/servicio_ecualizador_test.dartadd a new group usingSharedPreferences.setMockInitialValueswith raw corrupt strings (matches the file's existing convention). — done: new group"ServicioEcualizador — lectura tolerante y diagnostico (persistence-resilience)", with adebugPrintcapturesetUp/tearDownmirroringpersistencia_tolerante_test.dart's pattern; added a top-level_presetJson(nombre)helper and adart:convert/package:flutter/foundation.dartimport. - C2 Test C1 (partial matrix survivors + colon round-trip): seed
eq_presets_matriz_v1with 2 valid"stationUuid:deviceId"entries + 1 malformed value;cargar()->presetsMatrizhas exactly 2, keys byte-identical to input. (D6, Req: per-entry tolerant parsing) — done; malformed entry is Map-shaped but has'bandas': 'no-es-lista'(a String, not a List) soPresetEcualizador.desdeJsonthrows on the internal cast — this is what makes it genuinely RED pre-fix, not just a shape filter. - C3 Test C4 (colon round-trip with a skipped sibling, MAC-style deviceId): same shape but one surviving key's deviceId segment itself contains colons (e.g.
bt_a2dp:AA:BB:CC:DD:EE:FF); confirm the split-on-first-colon convention (servicio_ecualizador.dart:96-98) is untouched by the skip. (D6, Req: per-entry tolerant parsing) — done; the MAC-style key'station-X:bt_a2dp:AA:BB:CC:DD:EE:FF'survives byte-for-byte alongside a plain key, with 1 malformed sibling dropped. Sincecargar()auto-invokesmigrarClavesPlaceholder()first (which itself calls_leerMapaand splits every matrix key on the first colon), this test also proves that code path doesn't choke or misfire on a multi-colon surviving key. - C4 Test C2 (total corrupt map -> empty, explicit save still persists): seed
eq_preset_por_dispositivo_v1='{bad';cargar()->presetsDispositivoempty;guardarPresetDispositivo(...)still writes successfully (no flag exists for EQ). (D6, Req: explicit mutation restores authority) — done. - C5 Test C3 (corrupt principal -> flat + logged): seed
eq_preset_principal_v1='{bad';cargar().principal == PresetEcualizador.flat(existing fallback) AND a[PluriWave][persistencia]log line is now emitted (currently silent). (Req: diagnostics are developer-facing only) — done. - C6 Test:
_leerMapaStrings(nombresDispositivos) partial survivors — 1 bad value among 2 valid names. (D6, Req: per-entry tolerant parsing) — done; bad value is12345(not a String). - C7 Run the file — confirm RED for the new cases only; existing 20 cases stay green (untouched). — confirmed: 4/5 new tests genuinely RED pre-fix — C2/C3 got an empty
Setinstead of 2 survivor keys, C6 got{}instead of 2 survivor names, C5 gotlogs.any(...) == false(no log line existed yet). C4 passed VACUOUSLY pre-fix — exactly mirrors Phase A's A12 precedent: EQ never had a degraded-write flag in either the old or new code, so "guardarPresetDispositivostill writes successfully" was already true beforehand; its role is characterization/regression-lock for D6's "no flag" design choice (matching the spec's own EQ scenario wording), not a bug-fix proof — the genuine per-entry-tolerance bug evidence comes from C2/C3/C6. All 20 pre-existing cases stayed green throughout.
GREEN
- C8 Convert
_leerMapa(lib/servicios/servicio_ecualizador.dart:231-248) to delegate per-entry conversion toparseMapaTolerante<PresetEcualizador>(D1) — shared bypresetsPorDispositivoandpresetsMatriz; keep the existing outer try aroundjsonDecodefor total-failure (log + return{}, unchanged behavior). — done; outer catch now also callsregistrarSaltoPersistenciabefore returning{}(previously silent) — consistent with the design's "log degraded reads" statement and Phase A's A15 precedent of logging total-failure catches beyond a single dedicated test's literal scope. - C9 Convert
_leerMapaStrings(lines 260-269) the same way ->parseMapaTolerante<String>. — done; outer catch also now logs. - C10 Convert
_leerPresetsPorEmisora(lines 293-311) the same way ->parseMapaTolerante<PresetEcualizador>. — done; outer catch also now logs. NOTE: no NEW dedicated corruption test targets this method directly (C2-C6 exercise_leerMapa/_leerMapaStrings/_leerPresetPrincipal, not_leerPresetsPorEmisora) — its correctness rests on (a)parseMapaTolerante's own exhaustive Phase A unit tests, (b) being byte-for-byte the same conversion shape as_leerMapawhich C2/C3 DO exercise directly, and (c) existing healthy-path regression tests (4.1h, 4.1i) proving the wiring didn't regress normal reads. Flagged for reviewer awareness, matching Phase B's B8 precedent of documenting an intentionally-unexercised path rather than expanding scope beyond the assigned tasks. - C11 Add a skip-logger call inside
_leerPresetPrincipal's existing catch (line 288) — single-value key, logging-only change, no structural change. (D6, Req: diagnostics) — done. - C12 No flag/quarantine anywhere in this file (D6 intentional asymmetry vs. Alarms/Stations — EQ is explicit-only, presets trivially recreatable); read-modify-write callers (
guardarPresetDispositivo,guardarPresetMatriz,guardarNombresDispositivos,guardarPorEmisora) need no code change — they already read via the now-fixed tolerant readers. — confirmed: no flag field added; allguardarXxxmethods andguardarConfiguracionleft byte-for-byte unmodified; C4 independently proves the "no flag" contract holds (explicit write after total corruption still lands on disk). - C13 Run
test/servicios/servicio_ecualizador_test.dart— confirm new cases GREEN and all 20 pre-existing cases (multi-device, nombresDispositivos, placeholder migration) pass unmodified. — confirmed: 25/25 pass (20 pre-existing + 5 new).
REFACTOR + suite gate
- C14 Review naming/doc comments; confirm
migrarClavesPlaceholder(itself calls_leerMapa/_leerMapaStrings, lines 83-126) is unaffected — its own 5.x test group still passes. — confirmed: all 7 tests in the placeholder-migration group (5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.9) pass unchanged; C3 additionally exercises this exact call path (viacargar()) with a multi-colon surviving key with no incident. - C15 Targeted suite:
flutter test test/servicios/servicio_ecualizador_test.dart— full file green (only Glob match forservicio_ecualizador*). (Req: healthy reads unchanged) — confirmed: 25/25 pass. - C16
flutter analyze --no-fatal-infos— clean. — confirmed: ran plainflutter analyzeonlib/servicios/servicio_ecualizador.dart+test/servicios/servicio_ecualizador_test.dart— "No issues found!". - C17 Commit as work unit 3: "EQ presets — persistence corruption guard" (stacked-to-main, base: work-unit-2 branch). Depends on
persistencia_tolerante.dartfrom Phase A. — done: commit5e53a6cfix(eq): keep valid presets when stored maps are partially corrupt(2 files changed, 231 insertions, 15 deletions), stacked directly onmainon top of Phase B's13ad736. NOT pushed — orchestrator pushes.
Phase D: Final Sweep (not a separate work unit — closes the change) — COMPLETE (2026-07-11)
- D1
flutter analyze --no-fatal-infos— zero issues across all 5 changed/created source files (persistencia_tolerante.dart,servicio_alarmas.dart,alarma_musical.dart,estado_radio.dart,servicio_ecualizador.dart). — confirmed: ran plainflutter analyzewith NO path argument (whole-project sweep, strictly broader than the per-file requirement) — "No issues found!" (5.5s). - D2 Targeted full sweep in one command:
flutter test test/servicios/persistencia_tolerante_test.dart test/servicios/servicio_alarmas_cache_test.dart test/servicios/servicio_alarmas_proxima_test.dart test/servicios/servicio_alarmas_snooze_test.dart test/servicios/servicio_alarmas_corrupcion_test.dart test/estado/estado_radio_test.dart test/servicios/servicio_ecualizador_test.dart— all green. — confirmed: 62/62 pass in one run across all 7 files (persistencia_tolerante_test.dart, servicio_alarmas_cache_test.dart, servicio_alarmas_proxima_test.dart, servicio_alarmas_snooze_test.dart, servicio_alarmas_corrupcion_test.dart, estado_radio_test.dart, servicio_ecualizador_test.dart). - D3 If a full
flutter testrun is attempted instead and hangs, fall back to D2's explicit file list — known intermittent hang, pre-existing/unrelated (persdd-init/pluriwavecache andeq-audiofocus-reapplyprecedent); do not block delivery on it. — did not attempt a fullflutter testrun given this known issue; went straight to D2's targeted file list. - D4 Optional, non-blocking on-device/emulator sanity: launch once with a pre-existing (pre-change) alarm + custom station + EQ preset already saved; confirm all three still load correctly. Skip if no device/emulator is available — every behavior here is already Dart-testable and covered above; no l10n, no Kotlin changes in this capability. — left unchecked: no device/emulator available in this environment; explicitly optional per this task's own skip clause.
Requirement Traceability
| Spec Requirement | Satisfied by |
|---|---|
| Per-entry tolerant parsing preserves valid siblings | A10/A15, B2/B9-10, C2-C3/C6/C8-C10 |
| Entry identity is never fabricated | A13/A16, B2/B9 |
| Degraded read suppresses automatic writes / restores on authority | A11-A12/A17-A18, B4-B5/B11-B12, C4/C12 |
| Healthy reads are behaviorally unchanged | A21, B15, C13/C15, D2 |
| Diagnostics are developer-facing only, no l10n | A7, B8-B9, C5/C11, D1 |
Dependency Summary
Phase A -> Phase B -> Phase C -> Phase D, strictly sequential (stacked-to-main: each branch bases
on the previous). Within each phase: RED sub-tasks are independent of each other (parallel);
GREEN is sequential and depends on all of that phase's RED tests existing; REFACTOR + suite gate
is sequential and closes the phase. Phase B and Phase C both depend on persistencia_tolerante.dart
existing from Phase A — no other cross-phase code dependency.