Files
pluriwave/openspec/changes/archive/2026-07-11-persistence-corruption-guard/design.md
T
FreeTLab c65497e58a
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m49s
docs(openspec): archive persistence-corruption-guard and promote its spec
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/.
2026-07-11 17:34:36 +02:00

8.1 KiB

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).