# 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` + `parseMapaTolerante` (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).