docs(openspec): archive persistence-corruption-guard and promote its spec
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m49s

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/.
This commit is contained in:
2026-07-11 17:34:36 +02:00
parent 762e740c89
commit c65497e58a
9 changed files with 1002 additions and 0 deletions
@@ -0,0 +1,212 @@
# Archive Report: Persistence Corruption Guard
**Change**: persistence-corruption-guard
**Archived**: 2026-07-11
**Status**: ARCHIVED AND CLOSED (see Archive Integrity note — folder deletion pending, content fully preserved)
**Archive Path**: `openspec/changes/archive/2026-07-11-persistence-corruption-guard/`
**Spec Merged To**: `openspec/specs/persistence-resilience/spec.md` (new capability domain)
---
## Executive Summary
The **persistence-corruption-guard** change is now archived with a **PASS WITH WARNINGS** verification verdict (0 CRITICAL, 2 WARNING, 4 SUGGESTION at verify time). It closes the highest-severity class from the 2026-07-11 full-app silent-failure audit (CRITICAL-1 + CRITICAL-4): a corrupt or schema-incompatible persistence read was silently coerced to an empty state, and the next write then committed that empty state to disk — permanently destroying user data with zero signal. Alarms was the worst instance: `recalcularTodas()` auto-persists on cold start and every 60 seconds with no user action, so a single bad read could erase all alarms automatically. The fix applies one shared architecture (per-entry tolerant parsing, skip-never-fabricate identity, degraded-read write suppression) across three subsystems — alarms, custom radio stations, and EQ presets/device names — shipped as three independently-revertible stacked commits. All 60 code-completable tasks are done (61 total; the sole remaining item, D4, is an explicitly optional on-device sanity check with no functional gap). 62/62 targeted tests pass, `flutter analyze` reports 0 issues, and **WARNING W1 (a narrow uncaught-exception path introduced in `estado_radio.dart`) was fixed by the orchestrator after verification**, with a RED-GREEN regression test added and a 53/53 re-run of the affected suites. WARNING W2 (a documentation-only gap in the multi-batch apply-progress evidence table) is accepted as non-blocking.
---
## Artifact Traceability
| Artifact | Engram Observation ID | Location | Notes |
|----------|----------------------|----------|-------|
| Explore | (embedded, no separate observation) | `sdd/persistence-corruption-guard/explore.md` on-disk only | Root-cause audit trace across 3 subsystems, shared defect architecture |
| Proposal | #2350 | sdd/persistence-corruption-guard/proposal | Intent, scope (alarms/stations/EQ), shared 3-part fix approach, risks, rollback |
| Spec | #2351 | sdd/persistence-corruption-guard/spec; merged to openspec/specs/persistence-resilience/spec.md | 5 requirement groups, 10 scenarios; new `persistence-resilience` capability domain |
| Design | #2354 | sdd/persistence-corruption-guard/design | 6 architecture decisions (D1-D6), degraded-flag/write-authority matrix, file changes, testing strategy |
| Tasks | #2359 | sdd/persistence-corruption-guard/tasks | 61 checkboxes across Phases A-D; review workload forecast + stacked-to-main work-unit plan |
| Apply-Progress | #2360 | sdd/persistence-corruption-guard/apply-progress | 3 batches (Phase A, Phase B, Phase C+D) merged; 57/57 code tasks + 3/4 Phase D tasks complete |
| Verify-Report | #2363 | sdd/persistence-corruption-guard/verify-report | PASS WITH WARNINGS; 10/10 spec scenarios independently re-verified compliant; W1/W2 findings |
---
## Implementation Completion
### Phase A: Alarms (work unit 1, worst blast radius) — COMPLETE
| Phase | Description | Tasks | Status | Evidence |
|-------|-------------|-------|--------|----------|
| A | Shared tolerant-parse helper (`persistencia_tolerante.dart`) + `ServicioAlarmas` per-entry tolerant parse + `_lecturaAlarmasDegradada` guard on `recalcularTodas`/`_guardar` | A1-A23 (23) | 23/23 [x] | 19/19 targeted alarms suite green; `flutter analyze` clean; commit `65c1ac2` |
### Phase B: Custom Stations (work unit 2) — COMPLETE
| Phase | Description | Tasks | Status | Evidence |
|-------|-------------|-------|--------|----------|
| B | `EstadoRadio._cargarEmisorasCustom` parse-vs-IO split, `.corrupt` sidecar quarantine, `_customDegradado` guard on `_guardarEmisorasCustom` | B1-B17 (17) | 17/17 [x] | 18/18 `estado_radio_test.dart` green (13 pre-existing + 5 new); `flutter analyze` clean; commit `13ad736` |
### Phase C: Equalizer (work unit 3) — COMPLETE
| Phase | Description | Tasks | Status | Evidence |
|-------|-------------|-------|--------|----------|
| C | 4 `ServicioEcualizador` readers converted to `parseMapaTolerante`, principal-preset skip logging, no flag/quarantine (explicit-only, low recovery value) | C1-C17 (17) | 17/17 [x] | 25/25 `servicio_ecualizador_test.dart` green (20 pre-existing + 5 new); `flutter analyze` clean; commit `45b7fc8` |
### Phase D: Final Sweep — 3/4 COMPLETE (D4 optional, non-blocking)
| Phase | Description | Tasks | Status | Notes |
|-------|-------------|-------|--------|-------|
| D | Whole-project `flutter analyze` + targeted 7-file cross-phase test sweep; optional on-device sanity check | D1-D4 (4) | 3/4 [x] | D1/D2/D3 done (0 issues, 62/62 pass, hang-avoidance documented); D4 explicitly optional — no device/emulator available in this environment, every behavior already Dart-testable and covered |
**Task Summary**: 60 tasks complete (`[x]`), 1 task intentionally left `[ ]` with an explicit skip rationale (D4). **Total: 61 checkboxes, 0 unaccounted.**
---
## Test Results
### Unit Tests (independently re-executed at verify time, not trusted from self-report)
| Suite | Tests | Result | Notes |
|-------|-------|--------|-------|
| `persistencia_tolerante_test.dart` | 5/5 | PASS | New file — shared helper unit tests |
| `servicio_alarmas_corrupcion_test.dart` | 4/4 | PASS | New file — alarms corruption/degraded-guard cases |
| `servicio_alarmas_cache_test.dart`, `servicio_alarmas_proxima_test.dart`, `servicio_alarmas_snooze_test.dart` | included in 19/19 | PASS | Pre-existing, unmodified, regression-locked |
| `estado_radio_test.dart` | 18/18 | PASS | 13 pre-existing + 5 new |
| `servicio_ecualizador_test.dart` | 25/25 | PASS | 20 pre-existing + 5 new |
| **Total (targeted 7-file gate)** | **62/62** | **PASS** | Matches apply-progress D2 claim exactly, independently reproduced at verify time |
### Static Analysis
| Tool | Result | Coverage |
|------|--------|----------|
| `flutter analyze` | 0 issues | Whole project, independently reproduced at verify time |
### Coverage
- `flutter test --coverage` was launched as a background/informational check; did not complete within the verification report-writing window. Not blocking — Strict TDD treats coverage as informational only. Correctness is established by the pass/fail gate above plus direct source-diff inspection.
---
## Verification Summary
### Verdict
**PASS WITH WARNINGS** — 0 CRITICAL, 2 WARNING, 4 SUGGESTION at verify time.
All 10/10 spec scenarios independently verified compliant with named, re-executed passing tests. All 6 design decisions (D1-D6) verified against actual source diffs via `git show`, not self-reported summaries alone. 62/62 targeted tests and 0 `flutter analyze` issues independently reproduced. 60/61 tasks complete, with the sole incomplete task (D4) explicitly optional and non-blocking.
### WARNING Resolution (post-verify, before archive)
| Finding | Severity | Status at Archive |
|---------|----------|--------------------|
| **W1**`_archivoCustom()` path resolution left outside the IO try/catch in `estado_radio.dart`; a hypothetical `path_provider` resolution failure would throw uncaught into `EstadoRadio._init()`'s `Future.wait(...)`, also affecting sibling loads (`cargarPopulares`/`cargarFavoritos`/`cargarGruposFavoritos`) | WARNING | **CLOSED.** Fixed by the orchestrator post-verify (commit `316e9a7`/`48e74e6`): `_archivoCustom()` is now called inside its own try/catch in `_cargarEmisorasCustom()`, setting `_customDegradado=true` and logging via `registrarSaltoPersistencia` on failure instead of throwing. A RED-GREEN regression test was added. Targeted gate re-run: 53/53 green across the 4 persistence suites touched. Independently confirmed present in source at archive time via direct read of `lib/estado/estado_radio.dart`. |
| **W2** — Multi-batch apply-progress "TDD Cycle Evidence" table only carries a row for the final batch (Phase C); Phase A/B evidence lives inline in `tasks.md` instead of a consolidated table, likely lost on `topic_key` upsert across the 3 apply-progress save revisions | WARNING | **ACCEPTED-DOCUMENTED, non-blocking.** Not a RED/GREEN discipline failure — independently corroborated via source-diff inspection and the full regression re-run. Flagged as a process/tooling gap: future multi-batch changes should concatenate TDD Cycle Evidence rows across batches rather than keeping only the latest. No code or spec impact. |
### Compliance Matrix Snapshot
| Requirement | Scenarios | Compliant | Notes |
|-------------|-----------|-----------|-------|
| Per-entry tolerant parsing preserves valid siblings | 3 | 3 | alarms, stations, EQ maps |
| Entry identity is never fabricated | 2 | 2 | alarms (no id), stations (no uuid) |
| Degraded read suppresses automatic writes / restores on authority | 3 | 3 | alarms (flag), stations (parse-vs-IO split, see Note A), EQ (no flag, explicit-only, see Note B) |
| Healthy reads are behaviorally unchanged | 1 | 1 | full 62/62 regression re-run |
| Diagnostics are developer-facing only, no l10n | 1 | 1 | log-assertion tests + zero l10n file changes across all commits |
| **Total** | **10** | **10** | 0 deferred, 0 N/A, 0 untested |
Note A: design D5 splits stations' total-corruption into a parse-fail path (flag-free, restores immediately on the next explicit add/remove) and an IO-fail path (`_customDegradado` deliberately NOT lifted by an explicit mutation — only a subsequent clean/partial load clears it, to avoid clobbering a possibly-intact file). This narrows the spec's general "user intent wins" prose for that one sub-case without contradicting the written scenario (flagged as SUGGESTION S2 for a spec-wording fast-follow, non-blocking).
Note B: EQ has no automatic writer at all, so "no automatic write while degraded" is true by construction in both old and new code — this half of the EQ scenario is structurally vacuous (mirrors Phase A's own A12 precedent); the test's real value is proving the explicit-write-after-total-corruption round-trip still works (SUGGESTION S3, non-blocking).
---
## File Changes Summary
**Estimated Changed Lines**: ~1057 total across 3 stacked work-unit commits (Alarms 514, Stations 312, EQ 231), plus a small post-verify W1 fix commit.
**400-Line Budget Risk**: High (forecasted and mitigated by the tasks-phase stacked-to-main chain plan — Alarms → Stations → EQ, each independently shippable and revertible).
**Build Execution**: Not run (`flutter build` explicitly excluded per repo convention — known unrelated full-build/full-suite hang risk).
### New Files
- `lib/servicios/persistencia_tolerante.dart` — shared `parseListaTolerante<T>`, `parseMapaTolerante<V>`, `registrarSaltoPersistencia()` (D1)
- `test/servicios/persistencia_tolerante_test.dart` (5 tests)
- `test/servicios/servicio_alarmas_corrupcion_test.dart` (4 tests)
### Modified Files
- `lib/servicios/servicio_alarmas.dart` — tolerant `_parsear`, `_cacheRaw` normalization on partial load (D3), `_lecturaAlarmasDegradada` guard on `recalcularTodas`/`_guardar` (D4)
- `lib/modelos/alarma_musical.dart` — doc comment only, zero functional change (D2: caller hardens, model unchanged)
- `lib/estado/estado_radio.dart` — parse-vs-IO split, `.corrupt` sidecar quarantine, `_customDegradado` guard (D5); post-verify W1 fix wraps `_archivoCustom()` resolution in the same IO-fail treatment
- `lib/servicios/servicio_ecualizador.dart` — 4 readers converted to `parseMapaTolerante`, principal-preset skip logging (D6, no flag/quarantine)
- `test/estado/estado_radio_test.dart` — +5 tests, `_ArchivoEspia` spy, `_crearArchivoCustomRaw` helper, W1 regression test
- `test/servicios/servicio_ecualizador_test.dart` — +5 tests, `debugPrint` capture harness
### Localization
- `lib/l10n/*.arb`: **0 changes** — confirmed via `git show --stat` on all commits; this capability is diagnostics-only, developer-facing, no user-visible copy.
---
## Deviations from Design
**Documented and accepted, no design violation**:
1. **B8 (Phase B, Stations IO-fail split)**: `_archivoCustom()`'s own path resolution was initially called BEFORE the new `_leerContenidoCustom` try block, matching the task's literal scope ("read `archivo.readAsString()` in its own try"). This was self-disclosed by the apply agent as WARNING-worthy and confirmed as **W1** at verify time; closed post-verify (see WARNING Resolution above).
2. **C10 (Phase C, `_leerPresetsPorEmisora`)**: no dedicated new corruption test targets this method directly — its correctness rests on being byte-for-byte the same conversion shape as `_leerMapa` (directly tested by C2/C3) plus the shared helper's own exhaustive unit tests. Flagged and accepted as SUGGESTION S1, non-blocking.
3. **C4 / A12 (vacuous-RED characterization tests)**: EQ never had a write-suppression flag in either the old or new code, so "explicit write still succeeds after total corruption" cannot be made RED by construction for EQ. This is intentional regression-lock engineering against ever accidentally adding a flag to EQ (which would violate D6's explicit-only design), not a rigor gap. Accepted as SUGGESTION S3, non-blocking.
---
## Risks and Mitigations
| Risk | Likelihood | Status |
|------|------------|--------|
| Guard suppresses legitimate `recalcularTodas` writes | Med | Mitigated — flag triggers ONLY on a failed last read; existing recalc tests lock normal behavior; no-thrash independently verified with a real write-counting test |
| `_cacheRaw` normalizing rewrite loses survivor data | Low | Mitigated — non-destructive, logged, surviving valid entries only; verified via source-diff |
| EQ matrix key round-trip breakage (colon-delimited `station:device` keys) | Low | Mitigated — explicit round-trip tests including a MAC-style multi-colon key survivor case |
| Flag lifecycle leaks across sessions | Low | Mitigated — in-memory per instance, cleared on success/mutation (asymmetric for stations IO-fail by design, D5) |
| `_archivoCustom()` path-resolution failure escapes uncaught (W1) | Low probability, real if triggered | **CLOSED** — fixed post-verify, RED-GREEN regression test added |
---
## Next Steps
### Recommended, Non-Blocking
1. **S1**: add a dedicated corruption test for `_leerPresetsPorEmisora` (EQ per-station presets) for defense-in-depth, even though residual risk is already very low.
2. **S2**: fast-follow the spec text for Requirement 3 ("degraded top-level read suppresses automatic writes until authority is restored") to explicitly distinguish the decode/parse-failure sub-case (authority restorable by explicit mutation) from the IO/read-failure sub-case (D5's deliberately stricter, non-restorable-by-mutation station behavior), so a reader of the spec alone is not surprised by the asymmetry.
3. **S4**: none required — cardinality mismatch between spec wording (3 valid + 1 malformed) and covering tests (2 valid + 1 malformed) is behaviorally equivalent and non-blocking.
4. **W2 process fix**: when merging multi-batch apply-progress under one `topic_key`, concatenate each batch's TDD Cycle Evidence rows instead of keeping only the latest batch.
### Follow-Up Opportunities (Out of Scope for This Change)
- EQ/alarms total-corruption on an explicit mutation still forfeits the unreadable blob (accepted per the user-intent rule in the proposal). A symmetric SP `_corrupto` backup-key quarantine for alarms/EQ was considered and rejected as unnecessary complexity for this change; revisit only if a future reviewer requires zero-loss everywhere.
---
## Archive Integrity
**Moved From**: `openspec/changes/persistence-corruption-guard/``openspec/changes/archive/2026-07-11-persistence-corruption-guard/`
**Copy method**: This execution had access only to Read / Write / Edit / Glob / Engram tools — **no Bash, shell, or file-deletion tool was available**. All 6 artifact files (`explore.md`, `proposal.md`, `spec.md`, `design.md`, `tasks.md`, `verify-report.md`) were reproduced by reading the exact on-disk source content and writing it verbatim to the archive path (no retyping, paraphrasing, or summarizing). Fidelity was spot-checked by re-reading both source and copy at the tail of the two longest files (`tasks.md` line 154, `verify-report.md` line 145 — exact match) and at a unicode-heavy table section of `design.md` (lines 20-29, including `≥`, `→`, `—` characters — exact match).
**NOT a verified true move**: because no delete-capable tool was available in this execution context, the original source folder `openspec/changes/persistence-corruption-guard/` was **left untouched on disk** rather than deleted. This is a **known, disclosed gap**, not a silent failure: content is fully and faithfully duplicated at the archive path and merged into `openspec/specs/persistence-resilience/`, but the active `openspec/changes/` directory will still show `persistence-corruption-guard/` until a Bash-capable process (or a human) removes it. **Required follow-up**: run `Remove-Item -Recurse -Force openspec/changes/persistence-corruption-guard` (PowerShell) or `rm -rf openspec/changes/persistence-corruption-guard` (Bash) to complete the move, ideally after an independent `diff -r` confirms the two folders are identical.
**Spec Merged**: `openspec/specs/persistence-resilience/spec.md` created as a new capability domain (no prior main spec existed for this domain — verified via `Glob openspec/specs/**` returning `alarm-pre-notice-countdown`, `eq-audiofocus`, `multi-device-eq`, `bt-device-identity`, `native-alarms`, none named `persistence-resilience`).
**Artifacts Preserved**: All 6 openspec files (explore, proposal, spec, design, tasks, verify-report) + new `state.yaml` + this `archive-report.md`, all present at the archive path.
---
## Sign-Off
**Archive Ready For**: Production — no blocking issues.
**Blocking Issues**: None. W1 closed, W2 accepted-documented, D4 optional/non-blocking.
**Outstanding Non-Code Follow-Up**: Delete the original `openspec/changes/persistence-corruption-guard/` source folder (see Archive Integrity) — a filesystem-hygiene step, not a functional or spec-compliance gap.
**Recommendation**: No further code changes required for this change. Optional S1/S2 fast-follows may be picked up independently at any time.
---
## Appendix: Observation IDs for Traceability
- #2350: sdd/persistence-corruption-guard/proposal (intent, scope, shared 3-part fix approach)
- #2351: sdd/persistence-corruption-guard/spec (5 requirement groups, 10 scenarios, `persistence-resilience` capability)
- #2354: sdd/persistence-corruption-guard/design (6 architecture decisions D1-D6, degraded-flag/write-authority matrix)
- #2359: sdd/persistence-corruption-guard/tasks (61 checkboxes, stacked-to-main work-unit plan, review workload forecast)
- #2360: sdd/persistence-corruption-guard/apply-progress (3 batches merged, 57/57 code tasks + 3/4 Phase D)
- #2363: sdd/persistence-corruption-guard/verify-report (PASS WITH WARNINGS, 10/10 scenarios compliant, W1/W2 findings)
- (this report): sdd/persistence-corruption-guard/archive-report
All artifacts archived and accessible at `openspec/changes/archive/2026-07-11-persistence-corruption-guard/` with full change history preserved for audit trail.
@@ -0,0 +1,77 @@
# Design: Persistence Corruption Guard
## Technical Approach
Applies the proposal's `persistence-resilience` invariant to three stores. Two storage/write
models drive TWO guard mechanisms, not one:
- **Alarms** — SP key + AUTOMATIC writer (`recalcularTodas`: cold start + every 60s). Danger =
auto-wipe with no user action. Fix: per-entry tolerant parse + a degraded flag that muzzles
the automatic writer.
- **Stations & EQ** — explicit-only writers (no timer). Danger = the user's own next write
overwriting a corrupted-then-emptied store. Fix: per-entry tolerant parse (kills the common
case) + a subsystem-specific total-corruption policy.
Shared primitive: per-entry parse-skip-log, so one bad entry never blanks its siblings and every
skip/degradation logs `[PluriWave][persistencia] ...` (repo convention: `debugPrint`).
## Architecture Decisions
| ID | Decision | Rejected alternative | Rationale |
|----|----------|----------------------|-----------|
| D1 | New `lib/servicios/persistencia_tolerante.dart`: `parseListaTolerante<T>` + `parseMapaTolerante<V>` (parse each entry in own try, skip+log, return `(validas, saltadas)`) + `registrarSaltoPersistencia()`. Flag/quarantine POLICY stays per-file. | Inline try/catch at ~8 parse sites; or one mega-helper owning policy. | 8 sites share identical skip+log boilerplate → DRY + one uniform log format. Policy differs per subsystem → stays local. Dart 3.7 records make `(validas, saltadas)` idiomatic. |
| D2 | `AlarmaMusical.fromJson` keeps `id: json['id'] as String` UNCHANGED; harden the CALLER — `_parsear` wraps each `fromJson` in the per-entry try; bad/missing id throws → entry skipped+logged (best-effort `e['id']`). | Null-check returning a sentinel id. | Skip-never-fabricate — a sentinel is a ghost. The per-entry boundary tolerates ANY future required-field break, not just id; model contract stays honest. |
| D3 | **Alarm `_cacheRaw` coherence (flagged):** on PARTIAL load (container decodes, ≥1 skipped, ≥1 survives) cache `_cache=survivors` and `_cacheRaw=_serializar(survivors)` (normalized), NOT the corrupt raw. No write-on-read. Partial NEVER sets the flag / quarantines. | Force one `_guardar` on the read path; or leave `_cacheRaw=raw`. | Coherent raw==parsed → `recalcularTodas`'s dirty-guard (`nuevoRaw==actualRaw`) does NOT re-fire every 60s. Disk cleaned opportunistically by the next schedule-changing recalc (the cold-start recalc in practice) = ONE clean write, never a thrash. Verified: timer calls `recalcularTodas()` (not `cargar()`) and `_guardar` refreshes `_cacheRaw`, so the normalized cache holds between ticks. No surprising write-on-read. |
| D4 | **Alarm total-fail:** `_cache=empty`, `_lecturaAlarmasDegradada=true`, keep `_cacheRaw=raw`. `recalcularTodas` gets `if (_lecturaAlarmasDegradada) return config;` BEFORE its dirty-guard. Explicit mutations flow through `_guardar`, which clears the flag + persists. | Let the dirty-guard fire (writes empty → wipe). | Closes the headline auto-wipe without user action; `recalcularTodas` is the only automatic writer (native-sync is inert when in-memory is empty). Explicit creation overwriting a 0%-in-app-recoverable blob = accepted fresh start. |
| D5 | **Station total-corruption (load-bearing):** split `_cargarEmisorasCustom` catch. **Parse fail** (bytes read, invalid JSON) → rename file to `${path}.corrupt` sidecar (only if none exists; else drop the live copy), log, list `=[]`; live path now clean → next add/remove writes a fresh file. NO flag. **IO fail** (readAsString throws) → do NOT touch file, list `=[]`, `_customDegradado=true` → SUPPRESS `_guardarEmisorasCustom` this session; clears on next clean load. **Partial** → survivors, no flag. | block-and-log (permanent lockout); accept-and-overwrite (loses unreadable old stations — rejected by task constraint). | Quarantine uniquely satisfies preserve-old-data + no-lockout + user-intent. "Which action re-establishes authority / what it writes": none special — load quarantine clears the path, the next normal add/remove writes {survivors+change}; the unreadable payload is preserved out-of-band. Parse-vs-IO split avoids quarantining a transiently-unreadable GOOD file. Station URLs are user-discovered/high-value → worth the sidecar. |
| D6 | **EQ:** make the 4 readers per-entry tolerant (survivors kept; colon `station:device` keys are map keys → round-trip untouched) + log degraded reads. Total-corruption + explicit tweak overwrites (user intent). No flag, no quarantine. | Per-key quarantine to `_corrupto` SP keys. | EQ is explicit-only (no auto-writer to muzzle) and presets are trivially re-creatable (low recovery value). Per-entry tolerance fixes the realistic (partial) corruption; logging removes the silent loss. Total-corruption residual = accepted, documented tradeoff per the proposal's user-intent rule. Asymmetry with stations is intentional (data value + write model). |
## Degraded-flag / write-authority matrix
| Event | Alarms | Stations | EQ |
|-------|--------|----------|-----|
| Cold start, clean | normal | normal | normal |
| Cold start, PARTIAL | survivors; `_cacheRaw`=survivors; no write-on-read | survivors loaded | survivors loaded (per key) |
| Cold start, TOTAL parse-fail | empty; flag SET; no write | quarantine→`.corrupt`; path cleared; no flag | empty map (per key); logged |
| Cold start, IO error | n/a (SP) | `_customDegradado` SET; writes suppressed | n/a (SP) |
| 60s timer / pull-to-refresh (`recalcularTodas`) | WRITE SUPPRESSED while flag set; else dirty-guard | n/a (no timer) | n/a (no timer) |
| User save/delete alarm | `_guardar` clears flag + persists | — | — |
| Station add/remove | — | writes {survivors+change}; suppressed only under IO-degraded | — |
| EQ tweak | — | — | read-modify-write persists {survivors+change} |
## File Changes
| File | Action | Change |
|------|--------|--------|
| `lib/servicios/persistencia_tolerante.dart` | Create | shared per-entry helpers + logging (D1) |
| `lib/servicios/servicio_alarmas.dart` | Modify | tolerant `_parsear` (D1/D2), `_cacheRaw` normalize (D3), degraded flag + `recalcularTodas` guard + `_guardar` clear (D4) |
| `lib/modelos/alarma_musical.dart` | Modify | none functional (D2 keeps `as String`); doc comment only |
| `lib/estado/estado_radio.dart` | Modify | tolerant load + parse/IO split + eager quarantine + `_customDegradado` (D5) |
| `lib/servicios/servicio_ecualizador.dart` | Modify | 4 readers per-entry tolerant + logging (D6) |
## Testing Strategy
Existing suites LOCK the healthy path (must stay green): `servicio_alarmas_cache_test.dart`
(S3-R5/R7 dirty-guard + concurrency), `servicio_alarmas_proxima_test.dart`,
`servicio_ecualizador_test.dart` (20 cases incl. colon keys), `estado_radio_test.dart`.
New (RED→GREEN):
| Subsystem | Seed | Cases |
|-----------|------|-------|
| Alarms (`_PrefsEspia` on `alarmas_musicales_v1`) | one bad entry / `'{bad'` | A1 partial→survivors + no thrash (≤1 write over 2 `recalcularTodas`); A2 total→empty + zero writes; A3 explicit save after A2 writes once + clears flag; A4 missing-id skipped, no ghost |
| Stations (temp file via `resolverArchivoCustom`) | bad entry / `'{bad'` / IO-throw | B1 partial survivors; B2 total→`.corrupt` holds original bytes, live cleared; B3 add after B2 → fresh file has only new, sidecar preserved; B4 IO error → file untouched, write suppressed |
| EQ (`setMockInitialValues`) | one bad value / `'{bad'` | C1 partial map survivors + colon-key round-trip; C2 total→empty, explicit save still persists; C3 corrupt principal→`flat` + logged; C4 survivor colon round-trip with a skipped sibling |
## Migration / Rollout
No migration. Persisted payload format unchanged; revert = restore prior behavior. `.corrupt`
sidecar and degraded flags are runtime-only.
## Open Questions
- [ ] EQ/alarms total-corruption on explicit mutation forfeits the unreadable blob (accepted per
user-intent rule). Add SP `_corrupto` backup-key quarantine for symmetry with stations only if
reviewers require zero-loss everywhere.
- [ ] Confirm 400-line budget once quarantine + tests land; if exceeded, chain Alarms → Stations
→ EQ per the proposal's plan (Alarms first, highest severity).
@@ -0,0 +1,38 @@
# Exploration: persistence-corruption-guard
Source: full-app silent-failure audit (2026-07-11, engram `audit/2026-07-full-app-bug-classes` #2348). All mechanisms verified by reading live code; this change covers audit CRITICAL-1 and CRITICAL-4 (same architecture, three subsystems).
## The defect architecture (repeated 3×)
**Corrupt/failed read → silently coerced to empty state → next write persists the empty state → permanent user-data loss, zero signal.**
### Instance A — Alarms (worst: auto-persists with NO user action)
- `ServicioAlarmas._parsear()` (`lib/servicios/servicio_alarmas.dart:70-110`) wraps `jsonDecode` + per-entry `AlarmaMusical.fromJson` for the WHOLE list in one `catch (_) { return empty config; }` — no logging.
- `AlarmaMusical.fromJson` (`lib/modelos/alarma_musical.dart:147`) does `json['id'] as String` with no fallback (every other field has `?? default`) — ONE malformed/legacy entry throws and blanks the entire list.
- `_configActual()` caches the original corrupted raw string in `_cacheRaw` (L66) but the EMPTY config in `_cache`.
- `recalcularTodas()` (L201-220) — called as the second line of `EstadoAlarmas.inicializar()` on every cold start AND every 60s from the refresh timer — computes `nuevoRaw` from the empty list, finds it differs from `_cacheRaw` (the corrupted original), and calls `_guardar()`, overwriting disk with the empty list. **One bad read → all alarms permanently gone, automatically.**
### Instance B — Custom stations
- `EstadoRadio._cargarEmisorasCustom()` (`lib/estado/estado_radio.dart:503-521`): `catch (_) { _emisorasCustom = []; }` — not even a log. Any read failure (corruption, future `Emisora.fromMap` schema break) silently empties the user's custom stations.
- Next `agregarEmisoraCustom`/`eliminarEmisoraCustom``_guardarEmisorasCustom()` (L523-528) persists the near-empty list → **every previously saved custom station destroyed the first time the user adds or removes one.**
### Instance C — EQ presets / device names
- `ServicioEcualizador._leerMapa` / `_leerMapaStrings` / `_leerPresetPrincipal` / `_leerPresetsPorEmisora` (`lib/servicios/servicio_ecualizador.dart:245,266,288,308`): identical catch-to-empty shape for per-station/per-device/matrix presets and device names.
- Any `guardarXxx` call read-modify-writes through the corrupted-then-empty state, propagating the loss to disk on the user's next EQ tweak.
## Recommended fix shape
1. **Per-entry tolerant parsing** everywhere a LIST/MAP of entries is decoded: decode the container; parse each entry individually inside its own try; skip corrupt entries (log id/index + reason via debugPrint/developer.log); keep every valid entry. A single bad entry must never blank its siblings.
2. **Entry identity rule**: an entry whose `id` is missing/invalid is skipped (never fabricate an id — ghosts are worse than a skipped entry).
3. **Degraded-read guard (the load-bearing invariant)**: when the TOP-LEVEL decode fails (whole string unparseable), the in-memory state may be empty for the session BUT automatic persistence must be suppressed — `recalcularTodas()` (and any other write not initiated by an explicit user mutation) must NOT write while the last read was degraded. An explicit user mutation (save/delete alarm, add/remove station, EQ tweak) re-establishes write authority (user intent wins). Track per-subsystem (e.g. `_lecturaDegradada` flag set by the failed read, cleared on successful read or explicit mutation).
4. **Logging**: every skipped entry and every degraded read logs with enough context to diagnose from logcat (`[PluriWave]` prefix convention).
## Testability (all Dart-testable with existing harnesses)
- Seed SharedPreferences mock with: (a) one corrupt entry among valid ones → valid entries survive load, corrupt one skipped, `recalcularTodas()` does NOT rewrite the raw string destructively; (b) fully corrupt raw string → empty in-memory, `recalcularTodas()` writes NOTHING; explicit `guardarAlarma()` afterwards DOES write (user-intent rule).
- Same pattern for `estado_radio` custom stations and `servicio_ecualizador` map readers.
- Existing suites: `test/servicios/servicio_alarmas_*`, `test/estado/estado_radio_*`(check exact names), `test/servicios/servicio_ecualizador_test.dart` (20 cases) — extend, do not regress.
## Risks
- `recalcularTodas()` legitimately rewrites when occurrences advance — the degraded guard must ONLY suppress writes when the last read failed, not change normal recalc behavior (locked by existing tests).
- `_cacheRaw`/`_cache` coherence: fix must keep the raw-vs-parsed cache comparison meaningful after per-entry skips (a skipped entry means parsed != raw forever → would re-trigger save every cycle; decide: normalize raw after successful degraded-entry load via ONE explicit, logged, non-destructive rewrite of the SURVIVING entries — this is a design-phase decision, flag it).
- EQ maps: colon-delimited matrix keys (`station:device`) must round-trip unchanged for surviving entries.
@@ -0,0 +1,86 @@
# Proposal: Persistence Corruption Guard
## Intent
A corrupt or schema-incompatible persistence read is silently coerced to empty state; the next write commits that empty state to disk, permanently destroying user data with zero signal. Verified in 3 subsystems sharing ONE defect architecture (audit CRITICAL-1 + CRITICAL-4). Alarms is worst: `recalcularTodas()` auto-persists on cold start and every 60s with NO user action, so a single bad read erases all alarms automatically. Highest-severity class in the full-app audit; fixed as one change because the fix pattern is shared.
## Scope
### In Scope
- **Alarms**: `ServicioAlarmas._parsear` per-entry tolerant parse + `AlarmaMusical.fromJson` `id` hardening + degraded-read guard on `recalcularTodas`/auto-persist.
- **Custom stations**: `EstadoRadio._cargarEmisorasCustom` tolerant load + write guard on `_guardarEmisorasCustom`.
- **EQ presets/device names**: 4 `ServicioEcualizador` readers (`_leerMapa`, `_leerMapaStrings`, `_leerPresetPrincipal`, `_leerPresetsPorEmisora`) + write guards.
- Diagnostic logging (`[PluriWave]` prefix) for every skipped entry and degraded read.
### Out of Scope
- User-visible strings / l10n — logging only, no UI copy.
- Kotlin / native layer — pure Dart.
- Other audit fix-groups (separate changes).
- Schema versioning/migration of persisted payloads.
## Capabilities
### New Capabilities
- `persistence-resilience`: cross-cutting invariants for reading/writing persisted user collections — per-entry tolerant parsing, skip-never-fabricate identity, degraded-read automatic-write suppression.
### Modified Capabilities
- None. (Applies the new invariant to alarms/stations/EQ; no existing spec requirement changes.)
## Approach
Shared 3-part fix, applied per subsystem:
1. **Per-entry tolerant parse**: decode container, parse each entry in its own try; skip + log corrupt entries; keep all valid siblings.
2. **Skip-never-fabricate identity**: entry with missing/invalid `id` is skipped, never assigned a synthetic id (ghosts are worse than a gap).
3. **Degraded-read guard (load-bearing)**: a failed TOP-LEVEL decode sets a per-subsystem `_lecturaDegradada` flag; while set, AUTOMATIC writes (`recalcularTodas`, timer refresh) are suppressed. An explicit user mutation (save/delete alarm, add/remove station, EQ tweak) re-establishes write authority and clears the flag.
### Design-phase decisions (flagged; design finalizes)
- `_cacheRaw`/`_cache` coherence after per-entry skips: parsed != raw would re-trigger save every cycle. Proposed direction — after a partial load perform ONE explicit, logged, non-destructive rewrite normalizing the SURVIVING entries; design confirms mechanism.
- Exact flag scope/lifecycle per subsystem; EQ colon-delimited matrix keys (`station:device`) MUST round-trip unchanged.
## Affected Areas
| Area | Impact | Change |
|------|--------|--------|
| `lib/servicios/servicio_alarmas.dart` | Modified | tolerant `_parsear`, degraded guard on `recalcularTodas`/`_guardar` |
| `lib/modelos/alarma_musical.dart` | Modified | `fromJson` id hardening (skip signal) |
| `lib/estado/estado_radio.dart` | Modified | tolerant custom-station load + write guard |
| `lib/servicios/servicio_ecualizador.dart` | Modified | 4 readers tolerant + write guards |
| `test/servicios/`, `test/estado/` | Modified | new corruption/guard cases |
## Strict TDD Plan (outline)
All Dart-testable via SharedPreferences mocks; existing suites MUST NOT regress. RED per subsystem:
- (a) one corrupt entry among valid → valid survive, corrupt skipped, no destructive rewrite;
- (b) fully corrupt raw → empty in-memory AND automatic write suppressed;
- (c) explicit mutation after degraded read DOES persist (user-intent rule).
Extend `test/servicios/servicio_alarmas_*`, `test/estado/estado_radio_*`, `test/servicios/servicio_ecualizador_test.dart` (20 cases).
## Risks
| Risk | Likelihood | Mitigation |
|------|-----------|------------|
| Guard suppresses legitimate recalc writes | Med | Triggers ONLY on failed last read; existing recalc tests lock normal behavior |
| Normalizing rewrite loses survivor data | Low | Non-destructive, logged, surviving valid entries only; design-reviewed |
| EQ matrix key round-trip breakage | Low | Explicit round-trip test for `station:device` keys |
| Flag lifecycle leaks across sessions | Low | In-memory per instance; cleared on success/mutation |
## Rollback Plan
Pure Dart; no migration, schema, or native change. Persisted payload format is unchanged, so reverting the change commits/PR restores prior behavior with no data migration or forward-compat concern.
## Dependencies
- None external. Uses existing `shared_preferences` and test harnesses.
## Review Workload
Estimated 250-400 changed lines incl. tests across 4 source + ~3 test files. **400-line budget risk: Medium.** If tasks phase forecasts >400 lines, chain by subsystem (Alarms → Custom stations → EQ), each an autonomous slice with its own RED/GREEN and rollback boundary; Alarms slice first (highest severity).
## Success Criteria
- [ ] Corrupt single entry never blanks its siblings in any of the 3 subsystems.
- [ ] Fully corrupt read produces NO automatic disk write; data recoverable on next good read.
- [ ] Explicit user mutation after a degraded read persists correctly.
- [ ] No fabricated ids; skipped entries logged with context.
- [ ] All existing alarm/station/EQ suites pass; new corruption cases green.
@@ -0,0 +1,107 @@
# Spec: Persistence Corruption Guard
## New Capability: persistence-resilience
### Purpose
Cross-cutting invariants so a corrupt or schema-incompatible read of persisted user data (alarms, custom stations, EQ presets/device names) never silently destroys prior valid data: per-entry tolerant parsing, never-fabricate identity, and a degraded-read guard that suppresses automatic writes until a good read or an explicit user mutation restores write authority.
### Requirements
#### Requirement: Per-entry tolerant parsing preserves valid siblings
When a persisted collection decodes at the top level but one entry is malformed, the system MUST skip and log only that entry (index/key/id + reason), retaining every other valid entry. A skipped entry MUST NOT cause any sibling to be lost, regardless of whether a normalizing rewrite of the survivors later occurs — that mechanism is a design-time decision; this requirement constrains only the observable outcome.
##### Scenario: alarms — one corrupt alarm among N — Dart-testable via SharedPreferences mock seeding
- GIVEN `alarmas_musicales_v1` has 3 valid alarms and 1 malformed entry
- WHEN the config loads
- THEN `alarmas` MUST contain exactly the 3 valid entries; corrupt one skipped + logged
##### Scenario: custom stations — one corrupt station among N — Dart-testable via injected temp-file fixture
- GIVEN the custom-stations file has 2 valid stations and 1 entry missing required fields
- WHEN custom stations load
- THEN `_emisorasCustom` MUST contain exactly the 2 valid stations; corrupt one skipped + logged
##### Scenario: EQ maps — one corrupt value among N — Dart-testable via SharedPreferences mock seeding
- GIVEN a persisted EQ map (per-station/per-device/matrix presets, device names) has 3 valid entries and 1 entry whose value fails to parse
- WHEN that map loads
- THEN it MUST contain exactly the 3 valid entries under their original keys; corrupt one skipped + logged
---
#### Requirement: Entry identity is never fabricated
An entry whose identity field (alarm `id`, station `uuid`) is missing, empty, or the wrong type MUST be treated as corrupt and skipped per the requirement above. The system MUST NOT synthesize a substitute identity. (EQ entries are keyed by their persisted map key, always present by construction — no scenario needed here.)
##### Scenario: alarms — entry with no id is skipped, never assigned one — Dart-testable via SharedPreferences mock seeding
- GIVEN one persisted alarm entry has no `id` field
- WHEN the config loads
- THEN that entry MUST be absent, and no alarm with a freshly generated id MUST appear in its place
##### Scenario: custom stations — entry with no uuid is skipped, never assigned one — Dart-testable via injected temp-file fixture
- GIVEN one persisted station entry has no `uuid` field
- WHEN custom stations load
- THEN that entry MUST be absent, and no station with a freshly generated uuid MUST appear in its place
---
#### Requirement: Degraded top-level read suppresses automatic writes until authority is restored
When a persisted payload's top-level decode fails entirely, in-memory state MAY be empty for the session, but the system MUST NOT let any non-explicit write reach disk while degraded. Suppression MUST lift on the next successful read or an explicit user mutation, whichever comes first (user intent wins).
##### Scenario: alarms — recalcularTodas writes nothing after a degraded read; explicit save restores authority — Dart-testable via SharedPreferences mock seeding
- GIVEN `alarmas_musicales_v1` is unparseable
- WHEN the config loads and `recalcularTodas()` runs
- THEN no write occurs; the original string stays on disk
- AND WHEN the user calls `guardarAlarma` afterward
- THEN the write reaches disk and later automatic writes resume
##### Scenario: custom stations — no automatic write after a degraded file; explicit add/remove restores authority — Dart-testable via injected temp-file fixture
- GIVEN the custom-stations file is unparseable
- WHEN custom stations load
- THEN no write to the file occurs
- AND WHEN the user calls `agregarEmisoraCustom` or `eliminarEmisoraCustom`
- THEN that write reaches the file and normal writes resume
##### Scenario: EQ — no automatic write after a degraded key; explicit tweak restores authority — Dart-testable via SharedPreferences mock seeding
- GIVEN one of the 4 EQ persistence keys is unparseable
- WHEN the EQ config loads
- THEN no `guardarXxx` write occurs
- AND WHEN the user performs an explicit EQ tweak (e.g. `guardarPresetDispositivo`)
- THEN that write reaches disk and normal writes resume for that key
---
#### Requirement: Healthy reads are behaviorally unchanged
For a payload that decodes fully and validly at container and entry level, this capability MUST NOT alter resulting state, timing, or persisted bytes versus pre-change behavior.
##### Scenario: existing suites lock byte-for-byte parity — Dart-testable (regression)
- GIVEN the existing green suites (`test/servicios/servicio_alarmas_*`, `test/estado/estado_radio_*`, `test/servicios/servicio_ecualizador_test.dart`) seeded with well-formed data
- WHEN they run unmodified against the changed code
- THEN every assertion MUST still pass with no behavioral difference
---
#### Requirement: Diagnostics are developer-facing only
Every skipped entry and every degraded read MUST log with context (subsystem, key/id/index, reason) via the existing `[PluriWave]`-prefixed convention. This capability MUST NOT introduce any user-visible string or l10n key.
##### Scenario: skip and degraded-read events are logged, no l10n added — Dart-testable (log/hook assertion)
- GIVEN a corrupt entry is skipped or a top-level decode fails
- WHEN that event occurs
- THEN a `[PluriWave]`-prefixed log line MUST identify the subsystem and reason, and no string MUST be added to `lib/l10n/app_en.arb` or any locale file
### Localization
Not applicable — no user-visible copy is introduced by this capability; all diagnostics are developer-facing log lines only.
@@ -0,0 +1,78 @@
change: persistence-corruption-guard
archived_date: 2026-07-11
status: archived
archived_from: openspec/changes/persistence-corruption-guard
verification_verdict: "PASS WITH WARNINGS"
verify_report_observations:
- id: 2363
type: verify-report
critical_issues: 0
warnings: 2 (W1 CLOSED post-verify via orchestrator fix; W2 accepted-documented, non-blocking)
suggestions: 4 (non-blocking)
artifacts_migrated:
- explore.md
- proposal.md (obs #2350)
- spec.md (obs #2351, on-disk merged to openspec/specs/persistence-resilience/spec.md, new capability domain)
- design.md (obs #2354)
- tasks.md (obs #2359, 61 checkboxes: 60 done, 1 optional/non-blocking (D4) intentionally unchecked)
- apply-progress.md (obs #2360, Phases A/B/C complete across 3 batches, Phase D 3/4 complete)
- verify-report.md (obs #2363, PASS WITH WARNINGS)
phase_status:
Phase A (Alarms, work unit 1): COMPLETE - 23/23 tasks [x]
Phase B (Custom Stations, work unit 2): COMPLETE - 17/17 tasks [x]
Phase C (Equalizer, work unit 3): COMPLETE - 17/17 tasks [x]
Phase D (Final Sweep): 3/4 tasks [x] (D4 optional on-device/emulator sanity check — no device
available in this environment; every behavior is already Dart-testable and covered by the
automated gate; explicitly skippable per its own task text)
test_results:
total_changed_lines: "~1057 across 3 work-unit commits (Alarms 514, Stations 312, EQ 231)"
flutter_analyze: "0 issues (whole-project sweep, independently reproduced at verify time)"
targeted_suites:
- persistencia_tolerante_test.dart: new file, 5 tests
- servicio_alarmas_corrupcion_test.dart: new file, 4 tests
- servicio_alarmas_cache_test.dart, servicio_alarmas_proxima_test.dart, servicio_alarmas_snooze_test.dart: pre-existing, unmodified, part of the 19/19 alarms gate
- estado_radio_test.dart: 18/18 (13 pre-existing + 5 new)
- servicio_ecualizador_test.dart: 25/25 (20 pre-existing + 5 new)
total_tests_passing: "62/62 (independently re-executed at verify time, matches apply-progress D2 claim exactly)"
commits:
- 65c1ac2: "fix(alarm): stop corrupt entries and unreadable payloads from wiping saved alarms"
- 13ad736: "fix(radio): quarantine corrupt custom-station files instead of silently emptying them"
- 45b7fc8: "fix(eq): keep valid presets when stored maps are partially corrupt"
- 316e9a7 / 48e74e6: "post-verify fix: W1 archivoCustom() path resolution moved inside IO-fail treatment + RED-GREEN regression test"
notes: |
New shared helper lib/servicios/persistencia_tolerante.dart introduced (parseListaTolerante,
parseMapaTolerante, registrarSaltoPersistencia) — shared per-entry tolerant-parse primitive
across all 3 subsystems (D1).
W1 (archivoCustom() path resolution left outside the IO try/catch in
lib/estado/estado_radio.dart) was identified during verification and FIXED post-verify by the
orchestrator (commit 316e9a7/48e74e6): the resolution call is now wrapped in its own try/catch
inside _cargarEmisorasCustom(), setting _customDegradado=true and logging via
registrarSaltoPersistencia on failure instead of throwing uncaught into
EstadoRadio._init()'s Future.wait(...). A RED-GREEN regression test was added. Full targeted
gate re-run: 53/53 green across the 4 persistence suites touched by the fix. Independently
confirmed present in source at archive time via direct read of
lib/estado/estado_radio.dart (_cargarEmisorasCustom/_archivoCustom). W1 is CLOSED.
W2 (multi-batch apply-progress "TDD Cycle Evidence" table only carries a row for the final
batch, Phase A/B evidence lives inline in tasks.md instead of the consolidated table) is
accepted as a documented, non-blocking process/tooling gap — independently corroborated via
source-diff inspection and full regression re-run, not an actual RED/GREEN discipline failure.
D4 (optional on-device/emulator sanity check) intentionally left unchecked — no device/emulator
available in this environment; every behavior in this change is already Dart-testable and
covered by the 62/62 automated gate.
ARCHIVE TOOLING NOTE: this archive run had access only to Read/Write/Edit/Glob/Engram tools
(no Bash/shell/delete-capable tool in this execution context). All 6 artifacts were copied via
exact-content Read followed by verbatim Write (fidelity spot-checked against source, including
a full tail-line match on the two longest files and a unicode-heavy table section) rather than
via Bash `cp`. The original openspec/changes/persistence-corruption-guard/ source folder could
NOT be deleted (no delete-capable tool available) — see archive-report.md "Archive Integrity"
for the follow-up action required to complete the true move.
@@ -0,0 +1,153 @@
# 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)**
- [x] A1 Create `test/servicios/persistencia_tolerante_test.dart`: `parseListaTolerante` skips 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.
- [x] A2 Same file: `parseMapaTolerante` skips 1 bad VALUE among 3 map entries, original keys preserved untouched. (D1, Req: per-entry tolerant parsing) — done.
- [x] A3 Same file: both helpers on all-bad input return empty survivors, no exception escapes, still log. (D1, Req: degraded-read precondition) — done.
- [x] A4 Run `flutter test test/servicios/persistencia_tolerante_test.dart` — confirm RED (helpers undefined). — confirmed: compile error, `parseListaTolerante`/`parseMapaTolerante` not found.
**GREEN — shared tolerant-parse helper**
- [x] A5 Create `lib/servicios/persistencia_tolerante.dart`: `parseListaTolerante<T>` over an ALREADY-decoded `List<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.
- [x] A6 Same file: `parseMapaTolerante<V>` analogous for `Map<String, dynamic>?`, preserves original keys (needed later for EQ's colon-delimited matrix keys). (D1) — done.
- [x] A7 Same file: `registrarSaltoPersistencia({subsistema, detalle, razon})` -> `debugPrint('[PluriWave][persistencia] ...')`. No l10n string added. (Req: diagnostics are developer-facing only) — done.
- [x] A8 Re-run A1's test file — confirm GREEN. — confirmed: 5/5 pass.
**RED — Alarms tolerant parse + degraded flag**
- [x] A9 Create `test/servicios/servicio_alarmas_corrupcion_test.dart` with a local `_PrefsEspia` (mirrors `servicio_alarmas_cache_test.dart`'s pattern, add an `escriturasString` counter). — done; also seeds via constructor + exposes `lecturasString`.
- [x] A10 **Test A1 (no-thrash, explicit per orchestrator ask):** seed `alarmas_musicales_v1` with 2 valid + 1 malformed alarm entry; `cargar()` -> exactly 2 `alarmas`; call `recalcularTodas()` across 2 separate ticks; `escriturasString` grows 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 = an `id` field of the wrong type (`42`) so `AlarmaMusical.fromJson` throws — 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).
- [x] A11 Test A2 (total fail, zero writes): seed `'{bad'`; `cargar()` -> `alarmas` empty; `recalcularTodas()` -> `escriturasString` unchanged. (D4, Req: degraded read suppresses automatic writes) — done; also asserts the raw string on disk is byte-identical to the original corrupt seed.
- [x] A12 Test A3 (explicit save restores authority): after A2's seed, `guardarAlarma(...)` writes once and clears the flag; a following real-change `recalcularTodas()` writes normally again. (D4, Req: degraded read — explicit mutation restores authority) — done.
- [x] A13 Test A4 (missing id never fabricated): seed 1 entry with no `id` among 2 valid; `cargar()` -> exactly 2 alarmas, none carrying a freshly generated id. (D2, Req: entry identity is never fabricated) — done.
- [x] 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**
- [x] A15 Restructure `_parsear`/`_configActual` (`lib/servicios/servicio_alarmas.dart:59-110`): top-level `jsonDecode` failure -> `_lecturaAlarmasDegradada=true`, `_cache`=empty config, `_cacheRaw=raw` unchanged (D4); success -> parse `alarmas`/`vacaciones`/`excepciones` each via `parseListaTolerante` (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-field `data['x'] as List?` casts INSIDE the same try as `jsonDecode` (same boundary as the original code) so a container-level field of the wrong shape also degrades cleanly instead of throwing uncaught.
- [x] A16 Keep `AlarmaMusical.fromJson`'s `id: json['id'] as String` unchanged (`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).
- [x] A17 Add `bool _lecturaAlarmasDegradada = false;` field; in `recalcularTodas()` (line 201) add `if (_lecturaAlarmasDegradada) return config;` BEFORE the existing dirty-guard. (D4) — done.
- [x] A18 In `_guardar()` (line 424) add `_lecturaAlarmasDegradada = false;` — every explicit mutation funnels through `_guardar`, restoring write authority. (D4, Req: explicit mutation restores authority) — done.
- [x] A19 Run `servicio_alarmas_corrupcion_test.dart` — confirm GREEN (A1-A4 all pass). — confirmed: 4/4 pass.
**REFACTOR + suite gate**
- [x] 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.
- [x] 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.dart` exercise `ServicioAlarmasAndroid` (native channel bridge, a different class untouched here) — confirmed via import check, excluded from this gate. (Req: healthy reads unchanged) — confirmed: 19/19 pass.
- [x] A22 `flutter analyze --no-fatal-infos` on the changed/created source files — clean. — confirmed: ran plain `flutter analyze` (stricter, no info-suppression) on all 5 changed/created files — "No issues found!".
- [x] A23 Commit as work unit 1: "Alarms — persistence corruption guard" (stacked-to-main, base: main). — done: commit `b66eec0` `fix(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 separate `docs(openspec)` commits.
## Phase B: Custom Stations (work unit 2) — COMPLETE (2026-07-11)
**RED**
- [x] B1 In `test/estado/estado_radio_test.dart` add a local `_ArchivoEspia implements File` fake (mirrors `_PrefsEspia`'s implements+`noSuchMethod` pattern) with overridable `exists`/`readAsString`/`writeAsString`/`rename`/`path`, to force an IO failure independent of the OS. — done; injectable overrides via constructor + `noSuchMethod` fallback for the rest of `File`'s interface, plus a `writeAsStringCalls` counter used by the write-guard assertion.
- [x] 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 missing `uuid`; after `inicializar()`, 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 `_crearArchivoCustomRaw` helper; confirms exactly `{custom-1, custom-2}` survive.
- [x] B3 Test B2 (total parse-fail -> quarantine): temp file content `'{bad'`; after load, `emisoras_custom.json.corrupt` exists holding the ORIGINAL bytes, live path absent/empty. (D5 parse-fail) — done.
- [x] B4 Test B3 (add after B2 restores authority): after B2's quarantine, `agregarEmisoraCustom(nueva)` -> live file has ONLY `nueva`; `.corrupt` sidecar untouched. (D5, Req: explicit mutation restores authority) — done.
- [x] B5 Test B4 (IO error suppresses writes, NOT lifted by explicit add): `_ArchivoEspia` with `readAsString` throwing, `exists()` true; after load, `agregarEmisoraCustom(...)` -> the fake's `writeAsString` is 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, matching `agregarEmisoraCustom`'s existing reassign-then-persist structure).
- [x] B6 Test B5 (sidecar-already-exists edge case): temp dir pre-seeded with an existing `.corrupt` file (content X); live file has a NEW parse failure; after load, `.corrupt` still holds X untouched, live path cleared. (D5 "else drop the live copy") — done.
- [x] 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; `PathNotFoundException` reading 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**
- [x] B8 Split `_cargarEmisorasCustom`'s catch (`lib/estado/estado_radio.dart:503-521`): read `archivo.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 returns `null` (file untouched). Judgment call: `_archivoCustom()`'s own resolution (building the `File`/calling `getApplicationDocumentsDirectory()`) is called BEFORE this try, matching the task's literal scope ("read `archivo.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 stable `path_provider` call in production — flagged here for reviewer awareness, not fixed, to avoid unrequested scope creep.
- [x] B9 Decode/parse the read string via `parseListaTolerante<Emisora>` (D1); top-level `jsonDecode`/`as List` failure -> 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.
- [x] B10 Per-entry skips with survivors -> `_emisorasCustom`=survivors, no flag, no quarantine. (D5 partial) — done via `parseListaTolerante<Emisora>(data, Emisora.fromMap, subsistema: 'emisoras_custom', coleccion: 'emisoras_custom')`; `_customDegradado` explicitly cleared on this path (clean and partial reads both restore authority).
- [x] B11 Add `bool _customDegradado = false;` field; guard the top of `_guardarEmisorasCustom()` (line 523) with `if (_customDegradado) return;`. (D5) — done.
- [x] B12 Clear `_customDegradado=false` only inside `_cargarEmisorasCustom` on a clean/partial read — NOT inside `agregarEmisoraCustom`/`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`/`eliminarEmisoraCustom` unmodified.
- [x] 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**
- [x] B14 Review naming/doc comments; confirm `agregarEmitoraCustom`/`eliminarEmitoraCustom` compat 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 to `agregarEmisoraCustom`/`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).
- [x] B15 Targeted suite: `flutter test test/estado/estado_radio_test.dart` — full file green (only Glob match for `estado_radio*`). (Req: healthy reads unchanged) — confirmed: 18/18 pass; `Glob test/estado/estado_radio*` returns only this one file.
- [x] B16 `flutter analyze --no-fatal-infos` — clean. — confirmed: ran plain `flutter analyze` on `lib/estado/estado_radio.dart` + `test/estado/estado_radio_test.dart` — "No issues found!".
- [x] B17 Commit as work unit 2: "Custom stations — persistence corruption guard" (stacked-to-main, base: work-unit-1 branch). Depends on `persistencia_tolerante.dart` from Phase A. — done: commit `cbed0a6` `fix(radio): quarantine corrupt custom-station files instead of silently emptying them` (2 files changed, 312 insertions, 10 deletions), stacked directly on `main` on top of Phase A's `65c1ac2`. Not pushed — orchestrator pushes. `openspec/changes/persistence-corruption-guard/` left untracked/uncommitted, matching Phase A's convention of separate `docs(openspec)` commits.
## Phase C: Equalizer (work unit 3) — COMPLETE (2026-07-11)
**RED**
- [x] C1 In `test/servicios/servicio_ecualizador_test.dart` add a new group using `SharedPreferences.setMockInitialValues` with raw corrupt strings (matches the file's existing convention). — done: new group `"ServicioEcualizador — lectura tolerante y diagnostico (persistence-resilience)"`, with a `debugPrint` capture `setUp`/`tearDown` mirroring `persistencia_tolerante_test.dart`'s pattern; added a top-level `_presetJson(nombre)` helper and a `dart:convert`/`package:flutter/foundation.dart` import.
- [x] C2 Test C1 (partial matrix survivors + colon round-trip): seed `eq_presets_matriz_v1` with 2 valid `"stationUuid:deviceId"` entries + 1 malformed value; `cargar()` -> `presetsMatriz` has 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) so `PresetEcualizador.desdeJson` throws on the internal cast — this is what makes it genuinely RED pre-fix, not just a shape filter.
- [x] 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. Since `cargar()` auto-invokes `migrarClavesPlaceholder()` first (which itself calls `_leerMapa` and 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.
- [x] C4 Test C2 (total corrupt map -> empty, explicit save still persists): seed `eq_preset_por_dispositivo_v1='{bad'`; `cargar()` -> `presetsDispositivo` empty; `guardarPresetDispositivo(...)` still writes successfully (no flag exists for EQ). (D6, Req: explicit mutation restores authority) — done.
- [x] 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.
- [x] C6 Test: `_leerMapaStrings` (nombresDispositivos) partial survivors — 1 bad value among 2 valid names. (D6, Req: per-entry tolerant parsing) — done; bad value is `12345` (not a String).
- [x] 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 `Set` instead of 2 survivor keys, C6 got `{}` instead of 2 survivor names, C5 got `logs.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 "`guardarPresetDispositivo` still 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**
- [x] C8 Convert `_leerMapa` (`lib/servicios/servicio_ecualizador.dart:231-248`) to delegate per-entry conversion to `parseMapaTolerante<PresetEcualizador>` (D1) — shared by `presetsPorDispositivo` and `presetsMatriz`; keep the existing outer try around `jsonDecode` for total-failure (log + return `{}`, unchanged behavior). — done; outer catch now also calls `registrarSaltoPersistencia` before 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.
- [x] C9 Convert `_leerMapaStrings` (lines 260-269) the same way -> `parseMapaTolerante<String>`. — done; outer catch also now logs.
- [x] 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 `_leerMapa` which 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.
- [x] 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.
- [x] 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; all `guardarXxx` methods and `guardarConfiguracion` left byte-for-byte unmodified; C4 independently proves the "no flag" contract holds (explicit write after total corruption still lands on disk).
- [x] 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**
- [x] 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 (via `cargar()`) with a multi-colon surviving key with no incident.
- [x] C15 Targeted suite: `flutter test test/servicios/servicio_ecualizador_test.dart` — full file green (only Glob match for `servicio_ecualizador*`). (Req: healthy reads unchanged) — confirmed: 25/25 pass.
- [x] C16 `flutter analyze --no-fatal-infos` — clean. — confirmed: ran plain `flutter analyze` on `lib/servicios/servicio_ecualizador.dart` + `test/servicios/servicio_ecualizador_test.dart` — "No issues found!".
- [x] C17 Commit as work unit 3: "EQ presets — persistence corruption guard" (stacked-to-main, base: work-unit-2 branch). Depends on `persistencia_tolerante.dart` from Phase A. — done: commit `5e53a6c` `fix(eq): keep valid presets when stored maps are partially corrupt` (2 files changed, 231 insertions, 15 deletions), stacked directly on `main` on top of Phase B's `13ad736`. NOT pushed — orchestrator pushes.
## Phase D: Final Sweep (not a separate work unit — closes the change) — COMPLETE (2026-07-11)
- [x] 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 plain `flutter analyze` with NO path argument (whole-project sweep, strictly broader than the per-file requirement) — "No issues found!" (5.5s).
- [x] 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).
- [x] D3 If a full `flutter test` run is attempted instead and hangs, fall back to D2's explicit file list — known intermittent hang, pre-existing/unrelated (per `sdd-init/pluriwave` cache and `eq-audiofocus-reapply` precedent); do not block delivery on it. — did not attempt a full `flutter test` run 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.
@@ -0,0 +1,144 @@
## Verification Report
**Change**: persistence-corruption-guard
**Version**: N/A (no version field in spec; verified against spec observation #2351, design #2354, tasks #2359, apply-progress #2360, all dated 2026-07-11)
**Mode**: Strict TDD
### Completeness
| Metric | Value |
|--------|-------|
| Tasks total | 61 |
| Tasks complete | 60 |
| Tasks incomplete | 1 (D4, optional non-blocking on-device sanity check; no device/emulator available; explicitly skippable per its own task text) |
Phase A (Alarms): 23/23. Phase B (Custom Stations): 17/17. Phase C (Equalizer): 17/17. Phase D (Final Sweep): 3/4 (D4 intentionally unchecked).
Commits verified on main (pushed): 65c1ac2 (Alarms), 13ad736 (Stations), 45b7fc8 (EQ). Note: the apply-progress and tasks artifacts reference pre-push local hashes b66eec0, cbed0a6, 5e53a6c -- confirmed these are dangling objects, tree-identical to the pushed commits except a 1-line pubspec.yaml version bump each (this repos standard version-bump-on-push convention). No content discrepancy.
### Build & Tests Execution
**Build**: N/A -- flutter build intentionally not run per repo convention (known unrelated full-build/full-suite hang risk).
**Tests**: independently re-executed the exact targeted gate specified for this change.
```text
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
00:02 +62: All tests passed!
```
PASS -- 62 passed / 0 failed / 0 skipped -- matches apply-progress D2 claim exactly, independently reproduced.
**Analyze**:
```text
flutter analyze
Analyzing pluriwave...
No issues found! (ran in 5.5s)
```
PASS -- 0 issues, whole-project sweep -- matches D1 claim exactly, independently reproduced.
**Coverage**: flutter test --coverage launched on the same targeted file list as a background check; did not complete within this sessions report-writing window. Not blocking -- Strict TDD treats coverage as informational only. Correctness is already established by the pass/fail run above plus direct source-diff inspection below.
### Spec Compliance Matrix
| # | Requirement | Scenario | Test | Result |
|---|-------------|----------|------|--------|
| 1.1 | Per-entry tolerant parsing preserves valid siblings | alarms -- one corrupt alarm among N | servicio_alarmas_corrupcion_test.dart > "carga parcial (2 validas + 1 con id de tipo incorrecto): recalcularTodas no reescribe en cada tick (D3, no-thrash)" | PASS |
| 1.2 | Per-entry tolerant parsing preserves valid siblings | custom stations -- one corrupt station among N | estado_radio_test.dart > "entradas invalidas se omiten sin perder las validas ni fabricar uuid (D5 parcial)" | PASS |
| 1.3 | Per-entry tolerant parsing preserves valid siblings | EQ maps -- one corrupt value among N | servicio_ecualizador_test.dart > "C2 presetsMatriz: sobrevive parcial..." plus "C6 nombresDispositivos: sobrevive parcial..." | PASS |
| 2.1 | Entry identity is never fabricated | alarms -- entry with no id, never assigned one | servicio_alarmas_corrupcion_test.dart > "entrada sin id se omite y jamas se le fabrica uno nuevo (D2)" | PASS |
| 2.2 | Entry identity is never fabricated | custom stations -- entry with no uuid, never assigned one | estado_radio_test.dart > same test as 1.2, asserts exactly custom-1/custom-2 survive out of 4 seeded entries incl. a no-uuid one | PASS |
| 3.1 | Degraded read suppresses automatic writes / restores authority | alarms -- recalcularTodas writes nothing after degraded read; explicit save restores authority | servicio_alarmas_corrupcion_test.dart > "carga totalmente corrupta... (D4)" plus "guardarAlarma tras una lectura degradada... (D4)" | PASS |
| 3.2 | Degraded read suppresses automatic writes / restores authority | custom stations -- no automatic write after degraded file; explicit add/remove restores authority | estado_radio_test.dart > "JSON invalido al nivel superior pone en cuarentena... (D5 parse-fail)" plus "agregar tras la cuarentena escribe solo la nueva emisora... (D5)" | PASS, see Note A |
| 3.3 | Degraded read suppresses automatic writes / restores authority | EQ -- no automatic write after degraded key; explicit tweak restores authority | servicio_ecualizador_test.dart > "C4 presetsDispositivo totalmente corrupto... (sin bandera en EQ)" | PASS, see Note B |
| 4.1 | Healthy reads are behaviorally unchanged | existing suites lock byte-for-byte parity | All 7 targeted files, full regression re-run | PASS (62/62, including every pre-existing case) |
| 5.1 | Diagnostics are developer-facing only | skip/degraded events logged, no l10n added | persistencia_tolerante_test.dart (2 log-assertion tests) plus servicio_ecualizador_test.dart "C5 preset principal totalmente corrupto... loguea..." plus git show --stat on all 3 commits confirms zero l10n file changes | PASS |
Compliance summary: 10/10 scenarios compliant.
Note A (3.2): The spec Requirement prose says suppression must lift on the next successful read or an explicit user mutation, whichever comes first (user intent wins). The written scenario text ("the custom-stations file is unparseable") maps to the PARSE-fail path, which is fully compliant: quarantine sets no flag, and the next explicit add/remove writes immediately. Design D5 additionally splits out an IO-fail sub-case (file exists but readAsString throws) where the customDegradado flag is deliberately not lifted by an explicit agregarEmisoraCustom/eliminarEmisoraCustom call -- only a subsequent clean/partial load clears it. This is well-reasoned (avoids clobbering a possibly-intact file) and does not contradict the written scenario, but it is a narrower reading of the Requirements general "user intent wins" principle for that one sub-case. See SUGGESTION S2 below.
Note B (3.3): EQ has no automatic writer at all, so "no automatic write occurs while degraded" is true by construction in both the old and new code -- this half of the scenario is structurally vacuous, matching Phase A own precedent (A12). The tests real value is the second half (explicit guardarPresetDispositivo after total corruption still writes and round-trips). See adjudication of checklist item 5c below.
### Correctness (Static Evidence -- verified via direct git show diff inspection of all 3 commits, not apply-progress self-report alone)
| Requirement | Status | Notes |
|------------|--------|-------|
| D1 Shared helper, already-decoded containers | Implemented | parseListaTolerante and parseMapaTolerante in lib/servicios/persistencia_tolerante.dart -- confirmed both take pre-decoded containers, never call jsonDecode themselves; per-entry try/catch; registrarSaltoPersistencia uses debugPrint with the [PluriWave][persistencia] prefix |
| D2 Caller hardens, model unchanged | Implemented | AlarmaMusical.fromJson id cast is byte-for-byte unchanged (only a doc comment added); ServicioAlarmas._parsear wraps each fromJson call inside parseListaTolerante per-entry try |
| D3 cacheRaw normalization on partial load | Implemented | cacheRaw is normalized via serializar only when at least 1 skip occurred, otherwise kept as raw; confirmed the no-thrash test independently (writes grow by at most 1 across 2 recalcularTodas ticks) |
| D4 Alarms total-fail flag and write suppression | Implemented | lecturaAlarmasDegradada set true in the catch block (top-level decode failure), cacheRaw preserved untouched; recalcularTodas checks the flag before the dirty-guard, confirmed via full-function read; guardar clears the flag on every explicit mutation |
| D5 Stations parse-vs-IO split and eager quarantine | Implemented | leerContenidoCustom isolates IO failure (flag set true, file untouched); ponerEnCuarentena handles parse failure (rename to .corrupt sidecar, or drop live copy if a sidecar already exists, no flag); guardarEmisorasCustom guarded by the flag check; confirmed agregarEmisoraCustom/eliminarEmisoraCustom are byte-for-byte unmodified, flag is NOT cleared there, deliberate asymmetry with a dedicated test |
| D6 EQ tolerant readers, no flag | Implemented | All 3 map readers delegate to parseMapaTolerante; leerPresetPrincipal gained only a logging call in its existing catch, single-value key, no structural change; confirmed via grep that zero guardarXxx/guardarConfiguracion methods reference any flag or degradation field |
| No l10n strings introduced | Confirmed | git show --stat on all 3 commits touches zero files under lib/l10n/ |
### Coherence (Design)
| Decision | Followed? | Notes |
|----------|-----------|-------|
| D1 | Yes | Shared helper matches spec exactly, incl. Dart 3.7 record return type |
| D2 | Yes | Skip-never-fabricate enforced at the caller boundary only |
| D3 | Yes | No-thrash guarantee independently verified with a real write-counting test |
| D4 | Yes | Flag ordering (before dirty-guard) and clear-on-guardar both confirmed in source |
| D5 | Yes | Parse-vs-IO split and asymmetric flag-clearing both confirmed in source and dedicated tests |
| D6 | Yes | No flag/quarantine added; confirmed via direct grep of all guardarXxx methods |
### TDD Compliance
| Check | Result | Details |
|-------|--------|---------|
| TDD Evidence reported | Partial | The apply-progress formal "TDD Cycle Evidence" table only contains a row for Phase C (final batch); Phases A/B evidence lives as detailed inline per-task RED/GREEN notes in tasks.md instead of a consolidated table, likely lost on topic_key upsert across batches. See WARNING W2. |
| All tasks have tests | Yes | Every RED task (A1-A4, A9-A14, B1-B7, C1-C7) has a corresponding test file/case, confirmed via direct diff read, not just self-report |
| RED confirmed (tests exist) | Yes | 19 new test cases confirmed present across persistencia_tolerante_test.dart (new file, 5), servicio_alarmas_corrupcion_test.dart (new file, 4), estado_radio_test.dart (+5), servicio_ecualizador_test.dart (+5) |
| GREEN confirmed (tests pass) | Yes | 62/62 independently re-executed, not just trusted from apply-progress |
| Triangulation adequate | Yes | Each behavior has 2+ distinct cases, e.g. alarms: partial/total/explicit-restore/no-fabricate; stations: partial/parse-fail/restore/IO-fail/sidecar-exists |
| Safety Net for modified files | Yes | Pre-existing suites (19 alarms, 13 stations, 20 EQ) all confirmed still green post-change, run together with new tests in the same gate |
TDD Compliance: 5/6 checks fully passed, 1 partial (documentation completeness, not a RED/GREEN discipline failure -- independently corroborated via source inspection).
### Test Layer Distribution
| Layer | Tests | Files | Tools |
|-------|-------|-------|-------|
| Unit | 62 | 7 | flutter_test, SharedPreferences.setMockInitialValues, hand-rolled implements+noSuchMethod spies (_PrefsEspia, _ArchivoEspia) |
| Integration | 0 | 0 | not applicable -- no rendering/widget-tree or HTTP involved in this change |
| E2E | 0 | 0 | not applicable |
| Total | 62 | 7 | |
Unit-only coverage is appropriate here: this change is pure persistence/parsing logic with no UI, and the existing project convention already tests this layer exclusively at the unit level.
### Assertion Quality
Scanned all 4 new/modified test files (19 new test cases) for banned patterns: tautologies, orphan-empty-without-companion, ghost loops, precondition-blocks-execution, smoke-test-only, implementation-detail coupling, mock-heavy ratio.
Assertion quality: All assertions verify real behavior. Zero CRITICAL, zero WARNING findings.
Specifically evaluated and cleared:
- Write-count assertions (escriturasString, writeAsStringCalls) are not implementation-detail coupling -- counting actual writes to the real persistence boundary (SharedPreferences.setString / File.writeAsString) is the literal, spec-mandated observable behavior under test ("system MUST NOT let any non-explicit write reach disk"), not an internal-mock-call smell.
- The one "empty result" assertion without an immediate companion in the same test (persistencia_tolerante_test.dart null-input case) has companions elsewhere in the same file/group asserting non-empty survivors -- not an orphan check.
- No mock/assertion ratio exceeds 2:1 in any file; spies are shared setup fixtures, not per-assertion mocks.
### Issues Found
CRITICAL: None.
WARNING:
W1 -- archivoCustom() path resolution left outside the IO try/catch (lib/estado/estado_radio.dart around lines 531-534). Pre-change, the single catch-all in cargarEmisorasCustom also covered archivoCustom own resolution (getApplicationDocumentsDirectory via path_provider); post-change, that call happens before leerContenidoCustom try block, so a hypothetical resolution failure is now uncaught. cargarEmisorasCustom runs inside a Future.wait(...) in EstadoRadio._init() (around lines 274-279), so an uncaught exception there would reject the whole Future.wait, also affecting the sibling cargarPopulares/cargarFavoritos/cargarGruposFavoritos futures. Practical probability is low (path_provider directory resolution essentially never fails once the app is running) and this was self-disclosed by the apply agent (task B8 note) rather than hidden, but it is a real, verifiable narrowing of error-handling breadth in a change whose entire purpose is increasing persistence resilience. No test, old or new, exercises this path. Recommended fix: wrap the archivoCustom() call in the same or an outer try so a resolution failure degrades the same way every other failure mode in this file does.
W2 -- Multi-batch apply-progress "TDD Cycle Evidence" table only covers the final batch (Phase C). Phases A and B RED/GREEN/TRIANGULATE evidence is not carried forward as table rows in the merged artifact, topic_key upsert likely replaced rather than appended across the 3 save revisions. Not a rigor gap in the actual TDD discipline -- independently corroborated Phase A/B RED-to-GREEN cycle via direct source-diff inspection (pre-fix failure descriptions in tasks.md line up with the actual code delta) and via a full-suite re-run -- but it is a process/tooling gap that would force a future strict-TDD auditor to cross-reference tasks.md instead of relying on apply-progress alone. Recommended fix: when merging multi-batch apply-progress under one topic_key, concatenate each batch TDD Cycle Evidence rows rather than only keeping the latest batch.
SUGGESTION:
S1 -- leerPresetsPorEmisora (EQ per-station presets) has no dedicated new corruption test. Verified by direct source read that its conversion is structurally byte-for-byte identical to leerMapa (same parseMapaTolerante call shape, same parser closure, only the key-name string literal differs), and leerMapa is directly exercised by tests C2/C3. Combined with parseMapaTolerante own 5 exhaustive unit tests and the pre-existing healthy-path regression (4.1h/4.1i) that already exercises this exact method wiring, residual risk is very low. Acceptable as shipped; a follow-up one-line test would close the gap for defense-in-depth against future divergence between the two methods.
S2 -- Spec Requirement 3 "user intent wins" prose is broader than the one written scenario for stations. See Spec Compliance Matrix Note A above. Recommend a fast-follow to make the spec text explicitly distinguish decode/parse failure (authority restorable by explicit mutation) from IO/read failure (D5 deliberately stricter, non-restorable-by-mutation sub-case), so a future reader of the spec alone, without design.md, is not surprised by the asymmetry.
S3 -- EQ test C4 (and its Phase A precedent, A12) are structurally vacuous-RED characterization tests, not true bug-fix RED/GREEN pairs: EQ never had a write-suppression flag in either the old or new code, so "explicit write still succeeds after total corruption" was always true -- this specific assertion cannot be made RED by construction. This is sound engineering, a regression-lock against ever accidentally adding a flag to EQ which would violate D6, and is already well self-documented in tasks.md/apply-progress. Consider adding a short inline code comment at the test itself, not just the planning docs, so a future reader scanning the test file in isolation understands why it is a characterization test rather than a bug-proof.
S4 -- Minor cardinality mismatch between spec scenario wording and covering tests. Spec scenarios for alarms (1.1) and EQ (1.3) describe 3 valid entries plus 1 malformed; covering tests (A10, C2/C6) use 2 valid plus 1 malformed. Behaviorally equivalent and fully proves the invariant, skip-one-preserve-rest generalizes trivially from N=2 to N=3, non-blocking.
### Verdict
PASS WITH WARNINGS
Zero CRITICAL issues. All 10/10 spec scenarios independently verified compliant with named, re-executed passing tests. All 6 design decisions (D1-D6) verified against actual source diffs, not just self-reported summaries. 62/62 targeted tests and 0 flutter analyze issues independently reproduced. 60/61 tasks complete, with the sole incomplete task (D4) explicitly optional and non-blocking by its own text. Two WARNINGs are process/robustness nits: one narrow, low-probability uncaught-exception path introduced in estado_radio.dart, and one multi-batch TDD-evidence documentation gap. Neither breaks a spec requirement, blocks a task, or fails a test. Change is ready for sdd-archive; W1 is a trivial, low-risk fast-follow worth a follow-up commit but does not need to block archiving.
@@ -0,0 +1,107 @@
# Spec: Persistence Corruption Guard
## New Capability: persistence-resilience
### Purpose
Cross-cutting invariants so a corrupt or schema-incompatible read of persisted user data (alarms, custom stations, EQ presets/device names) never silently destroys prior valid data: per-entry tolerant parsing, never-fabricate identity, and a degraded-read guard that suppresses automatic writes until a good read or an explicit user mutation restores write authority.
### Requirements
#### Requirement: Per-entry tolerant parsing preserves valid siblings
When a persisted collection decodes at the top level but one entry is malformed, the system MUST skip and log only that entry (index/key/id + reason), retaining every other valid entry. A skipped entry MUST NOT cause any sibling to be lost, regardless of whether a normalizing rewrite of the survivors later occurs — that mechanism is a design-time decision; this requirement constrains only the observable outcome.
##### Scenario: alarms — one corrupt alarm among N — Dart-testable via SharedPreferences mock seeding
- GIVEN `alarmas_musicales_v1` has 3 valid alarms and 1 malformed entry
- WHEN the config loads
- THEN `alarmas` MUST contain exactly the 3 valid entries; corrupt one skipped + logged
##### Scenario: custom stations — one corrupt station among N — Dart-testable via injected temp-file fixture
- GIVEN the custom-stations file has 2 valid stations and 1 entry missing required fields
- WHEN custom stations load
- THEN `_emisorasCustom` MUST contain exactly the 2 valid stations; corrupt one skipped + logged
##### Scenario: EQ maps — one corrupt value among N — Dart-testable via SharedPreferences mock seeding
- GIVEN a persisted EQ map (per-station/per-device/matrix presets, device names) has 3 valid entries and 1 entry whose value fails to parse
- WHEN that map loads
- THEN it MUST contain exactly the 3 valid entries under their original keys; corrupt one skipped + logged
---
#### Requirement: Entry identity is never fabricated
An entry whose identity field (alarm `id`, station `uuid`) is missing, empty, or the wrong type MUST be treated as corrupt and skipped per the requirement above. The system MUST NOT synthesize a substitute identity. (EQ entries are keyed by their persisted map key, always present by construction — no scenario needed here.)
##### Scenario: alarms — entry with no id is skipped, never assigned one — Dart-testable via SharedPreferences mock seeding
- GIVEN one persisted alarm entry has no `id` field
- WHEN the config loads
- THEN that entry MUST be absent, and no alarm with a freshly generated id MUST appear in its place
##### Scenario: custom stations — entry with no uuid is skipped, never assigned one — Dart-testable via injected temp-file fixture
- GIVEN one persisted station entry has no `uuid` field
- WHEN custom stations load
- THEN that entry MUST be absent, and no station with a freshly generated uuid MUST appear in its place
---
#### Requirement: Degraded top-level read suppresses automatic writes until authority is restored
When a persisted payload's top-level decode fails entirely, in-memory state MAY be empty for the session, but the system MUST NOT let any non-explicit write reach disk while degraded. Suppression MUST lift on the next successful read or an explicit user mutation, whichever comes first (user intent wins).
##### Scenario: alarms — recalcularTodas writes nothing after a degraded read; explicit save restores authority — Dart-testable via SharedPreferences mock seeding
- GIVEN `alarmas_musicales_v1` is unparseable
- WHEN the config loads and `recalcularTodas()` runs
- THEN no write occurs; the original string stays on disk
- AND WHEN the user calls `guardarAlarma` afterward
- THEN the write reaches disk and later automatic writes resume
##### Scenario: custom stations — no automatic write after a degraded file; explicit add/remove restores authority — Dart-testable via injected temp-file fixture
- GIVEN the custom-stations file is unparseable
- WHEN custom stations load
- THEN no write to the file occurs
- AND WHEN the user calls `agregarEmisoraCustom` or `eliminarEmisoraCustom`
- THEN that write reaches the file and normal writes resume
##### Scenario: EQ — no automatic write after a degraded key; explicit tweak restores authority — Dart-testable via SharedPreferences mock seeding
- GIVEN one of the 4 EQ persistence keys is unparseable
- WHEN the EQ config loads
- THEN no `guardarXxx` write occurs
- AND WHEN the user performs an explicit EQ tweak (e.g. `guardarPresetDispositivo`)
- THEN that write reaches disk and normal writes resume for that key
---
#### Requirement: Healthy reads are behaviorally unchanged
For a payload that decodes fully and validly at container and entry level, this capability MUST NOT alter resulting state, timing, or persisted bytes versus pre-change behavior.
##### Scenario: existing suites lock byte-for-byte parity — Dart-testable (regression)
- GIVEN the existing green suites (`test/servicios/servicio_alarmas_*`, `test/estado/estado_radio_*`, `test/servicios/servicio_ecualizador_test.dart`) seeded with well-formed data
- WHEN they run unmodified against the changed code
- THEN every assertion MUST still pass with no behavioral difference
---
#### Requirement: Diagnostics are developer-facing only
Every skipped entry and every degraded read MUST log with context (subsystem, key/id/index, reason) via the existing `[PluriWave]`-prefixed convention. This capability MUST NOT introduce any user-visible string or l10n key.
##### Scenario: skip and degraded-read events are logged, no l10n added — Dart-testable (log/hook assertion)
- GIVEN a corrupt entry is skipped or a top-level decode fails
- WHEN that event occurs
- THEN a `[PluriWave]`-prefixed log line MUST identify the subsystem and reason, and no string MUST be added to `lib/l10n/app_en.arb` or any locale file
### Localization
Not applicable — no user-visible copy is introduced by this capability; all diagnostics are developer-facing log lines only.