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