- Status: done
- Date: 2026-05-27
- Specs touched:
HEALTH.md# Persistence,LOGGING.md,BOOT.md
Closes the in-memory-only gap noted in 0019. Active health issues now survive brain restarts via a health_issues SQLite table. The store write-through is transparent to callers: Raise and Clear still return transition bools; the Manager persists behind the scenes. Boot-time LoadFromStore() restores the registry before the HTTP server starts, so the dashboard never sees a transient "all clear" after restart.
Note: 0021 labeled "0022" as the LUKS/TPM-unseal slice. That work is now 0023 (the next testing-lane slice). The health persistence slice took this number because it was the next pending implementation slice.
Added to the migrate() block alongside the existing tables. Schema:
id TEXT, instance_key TEXT (DEFAULT ''), category, severity, tier INTEGER,
blocks_writes / blocks_apps / blocks_users INTEGER (0/1),
summary, details, raised_at INTEGER (epoch ms), last_checked_at INTEGER (epoch ms)
PRIMARY KEY (id, instance_key)
INSERT OR REPLACE handles the upsert pattern. No triggers — unlike audit_events, health issues are legitimately mutable (raise → refresh → clear cycles). instance_key DEFAULT '' avoids NULL-handling for box-wide issues.
Three methods on *Store, all imported from internal/health for the Issue type (store imports health; health does not import store — layer boundary preserved):
UpsertHealthIssue(health.Issue) error— INSERT OR REPLACE, updateslast_checked_aton re-raiseDeleteHealthIssue(id, instanceKey string) error— idempotent, no error on missing rowListHealthIssues() ([]health.Issue, error)— SELECT * ordered by raised_at, for boot-time load only
type HealthStore interface {
UpsertHealthIssue(Issue) error
DeleteHealthIssue(id, instanceKey string) error
ListHealthIssues() ([]Issue, error)
}Declared in the consumer package (health) so the Manager doesn't import store (CLAUDE.md layer-boundary rule). *store.Store satisfies it.
NewManager(store HealthStore) *Manager— takes the store at construction time; nil means no persistence (all existing tests pass nil)Raise()— persists the issue to the store outside the lock (reads the issue snapshot while holding the lock, releases, then callsUpsertHealthIssue). Both new raises and refreshes are persisted to keeplast_checked_atcurrent in SQLiteClear()— callsDeleteHealthIssueafter releasing the lock, only on actual transitionsApplyStorageFindings()— collectstoUpsertandtoDeleteslices under the single lock (preserving the atomic reconcile invariant), then calls the store aftermu.Unlock(). This keeps the lock-free-from-I/O discipline without splitting the critical sectionLoadFromStore() error— new method, readsListHealthIssues()and populatesm.activeunder the lock; no-op when store is nil; non-fatal error path in main.go
health.NewManager(st)— passes the real storehealthMgr.LoadFromStore()called after store init, before the HTTP server starts; error is Warn-logged, not fatalpullStorageHealthandstorageHealthPollLoopnow take*audit.Recorder; emithealth.issue.raised/health.issue.clearedaudit records on transitions (count-level records, system actor)
ActionHealthIssueRaised = "health.issue.raised"
ActionHealthIssueCleared = "health.issue.cleared"internal/store/health_test.go (7 new tests): upsert + list round-trip, idempotent re-upsert, delete, no-op delete on missing row, multiple rows, instance-key round-trip, bool-field round-trip. All use the real SQLite via open(t).
internal/health/health_test.go (10 new tests): Manager with fakeStore stub — raise calls UpsertHealthIssue, re-raise also upserts (refresh), clear calls DeleteHealthIssue, clear-of-nothing does not delete, LoadFromStore restores active set, nil-store safety for all three paths, ApplyStorageFindings persists via store.
All 23 health tests + all 29 store tests pass.
HEALTH.md# Persistence: "Active issues live in the brain's SQLite (health_issuestable). They survive brain restarts." — now realized.HEALTH.md# Persistence: "The history of raises and clears is in theaudit_eventstable." — wired at thepullStorageHealthcall site with count-level system records.BOOT.md# Startup sequence: health registry is restored from SQLite before the brain begins serving requests.
- Audit records are count-level, not per-issue.
pullStorageHealthemits onehealth.issue.raisedrecord with{"count": N}rather than one record per issue ID. Per-issue granularity would requireApplyStorageFindingsto return affected IDs rather than counts — deferred to the slice that extends the reconciliation API. (Resolved in per-issue-health-audit.md.) - No API changes.
GET /api/v1/healthalready readsm.List()from the in-memory Manager — no change needed; the manager is now the authoritative view whether populated from live detection or from SQLite. - No notification wiring. Health raise/clear → notification center is its own slice per
NOTIFICATIONS.md.
- 0023: LUKS root + first-boot TPM enrollment + unseal verification (originally labeled 0022 in 0021's "what's next" — renumbered here). Builds on the QEMU medium-lane scaffolding from 0021.
- Done (per-issue-health-audit.md): Per-issue audit records.
ApplyStorageFindingsnow returns affectedIssueKeys so audit records target{kind: "health_issue", id: "<id>"}instead of a bulk count record. - Notification wiring.
NOTIFICATIONS.md— on health issue raise (transition), emit a notification to admin users.