- Status: done
- Date: 2026-06-01
- Specs touched: docs/specs/HEALTH.md (locus-C catalog row marked built)
- Issue: #36 (closes).
Landed brain-db-corrupt as a locus-C (brain-owned periodic) health detector. The brain runs SQLite's PRAGMA integrity_check against its own database and reconciles a brain-db-corrupt issue: raise when the result is anything other than ok, clear when it's ok. Brain-only — no host-agent change, no dependency on the GET /v1/health/system transport (#34). The brain owns this state directly (it is its database), so the check needs no host round-trip.
Changes:
internal/health/health.go— registered thebrain-db-corruptdefinition inbuiltinDefinitions(): categorystate, severitycritical, Tier 2, blocks writes and apps and users (HEALTH.md # State: "nearly all ops"). Persisted (notNoPersist). The detector reconciles through the existing genericManager.Raise/Clear, exactly like the locus-Cstore-write-failedprecedent — no new Manager API.internal/store/health.go— addedIntegrityCheck() (string, error): runsPRAGMA integrity_check, joins the result rows with newlines, returns"ok"for a sound database and a (possibly multi-line) corruption report otherwise. The SQLite query lives at the persistence boundary (the store), per the issue and CLAUDE.md's layer rule, not in the health package. Read-only; runs on the brain's single serialized connection.cmd/brain/main.go— added the detector:const dbIntegrityCheckPeriod = 6 * time.Hour— the spec cadence as a constant, not an env knob (the value is pinned; nothing to tune per-deployment).integrityChecker— a one-method consumer-side interface (IntegrityCheck()), satisfied by*store.Store, so the check is unit-testable with a fake.checkBrainDBIntegrity(...)— runs the check, compares to"ok", raises/clearsbrain-db-corruptwith the integrity report indetails, and emits the per-issue audit + notification fan-out (mirrorspullStorageHealth).brainDBIntegrityLoop(...)— runs the boot check inside the goroutine (not synchronously before serving), then re-checks every 6h.- Wired into
main():go brainDBIntegrityLoop(...)alongside the storage-health poll, sharingpollCtx.
- HEALTH.md # State (
brain-db-corruptrow): critical / blocks nearly all ops / Tier 2. ✓ - HEALTH.md # Detector catalog, locus C (
brain-db-corrupt|PRAGMA integrity_check| boot + 6h | result ≠ok). Marked*(built)*in the same change. ✓ - HEALTH.md # Stance ("if the brain can possibly run, it runs"): the boot check is best-effort and non-blocking — it runs on its own goroutine after the brain is already serving, so a corrupt DB raises a banner but never gates startup. The brain-can't-boot path is
bootstrap-state-mismatch/ recovery, a distinct issue. ✓ (Design clarification recorded in the issue, 2026-05-31.) - HEALTH.md # Lifecycle / LOGGING knock-on: each raise/clear writes one
health.issue.*audit record (via the sharedemitHealthTransitions). ✓ - HEALTH.md # Cross-cutting detector policy "last-checked is always fresh": a steady corruption refreshes
last_checked_atevery run without re-raising (existingraiseLocked+ unconditional upsert; covered by test). ✓
- Result parsing = exact equality against
"ok".PRAGMA integrity_checkreturns one rowokwhen sound, or up to 100 rows of error text when not.IntegrityCheckjoins the rows; the detector raises when the joined string ≠"ok". The report becomes the issue'sdetails(and thus the diagnostic bundle's), so the technical specifics survive for support. - 1-shot, no debounce. Issue #36 mandates this ("A failing check is authoritative — no debounce"): a
PRAGMA integrity_checkverdict is definitive, not a noisy threshold sample, so the detector raises/clears on the first reading and keeps no consecutive-sample state. This is a deliberate override of HEALTH.md's cross-cutting locus-C debounce default ("raise on 2 consecutive bad samples") — not the locus-A/D authoritative-signal exception, which does not list locus C. The override is recorded as a per-row note in the HEALTH.md locus-C catalog, exercising the policy's own "these defaults apply … unless its row overrides them" clause, so spec and code agree. Thestore-write-failedprecedent is the nearest sibling — a 1-shot brain-state check. A query error (the check couldn't run at all) is treated as inconclusive: it neither raises nor clears, so a transient I/O blip leaves the issue state intact — and corruption severe enough to break thePRAGMAquery itself surfaces through thestore-write-failedfallback (a failed health-row write), not through this detector. - Persisted, not
NoPersist. Unlikestore-write-failed(which exists precisely when writes are broken),integrity_checkcommonly flags corruption that still permits a row write (a damaged index page, freelist, etc.), and the banner should survive a restart — so the issue persists like every other. The genericstore-write-failedfallback insideManager.Raisealready covers the case where the corruption also breaks the upsert (both issues then surface). On the next boot, the boot check re-reconciles: still-corrupt re-raises (idempotent), repaired/replaced DB clears the restored stale issue.
- Notification allowlist entry deferred (not "undecided").
brain-db-corruptsurfaces today as a health banner (GET /api/v1/health), not a pushed dashboard notification, because it isn't ininternal/notifyhealthRulesyet. The policy is not open:NOTIFICATIONS.md# The notification list routes system criticals to Admin, and HEALTH.md # Knock-ons lists "storage + system criticals" as the allowlist —brain-db-corruptqualifies. It's left unwired here on purpose, the documented incremental-wiring pattern (disk-full,version-mismatch,schema-migration-failedare likewise on the spec allowlist but absent fromhealthRulesuntil wired). Wiring it is more than a map key: ahealthRulecarries user-facing notification copy + a Tier-2 action route (the "restore from backup" flow), which is notification-UX owned by the notification workstream, not this detector PR.checkBrainDBIntegrityalready callsemitHealthNotifications(symmetric withpullStorageHealth), so the path is a no-op forbrain-db-corruptonly until thathealthRulesentry lands — then it's live with no detector change. - No
brain-db-corruptactions wired. TheIssue.Actionslist is deferred project-wide (seeinternal/healthIssuedoc comment); the Tier-2 "restore from backup" action lands with the backup surface (STORAGE.mdbackup architecture is itself deferred). - The corrupt path is tested with a fake, not a corrupted file. Deterministically corrupting a live SQLite file to make
integrity_checkfail is flaky; the store-layer test asserts a known-good DB returnsok, and the raise/clear/refresh behaviour is driven at thecmd/brainlayer with afakeIntegrityChecker.
internal/store/health_test.go—TestIntegrityCheck_HealthyDBReturnsOk: a freshly-migrated store passesintegrity_checkand reports"ok"(#36 Done-when: store-layer good-DB test).internal/health/health_test.go—TestList_BrainDBCorruptDefinitionpins the registered metadata (state / critical / Tier 2 / blocks writes+apps+users).cmd/brain/main_test.go— drivescheckBrainDBIntegritywith afakeIntegrityChecker:CorruptRaises— a non-okresult raisesbrain-db-corrupt, writes one raised audit record, and carries the integrity output indetails(#36 Done-when).OkClears— anokresult clears a prior corruption and writes the clear record (#36 Done-when).OkNoIssueIsNoop— the steady-healthy path raises nothing and audits/notifies nothing.SteadyCorruptRefreshesWithoutReaudit— a persistent corruption raises once; the next check refresheslast_checked_atwithout re-raising/re-auditing, and leavesraised_atuntouched.QueryErrorLeavesStateUnchanged— a failedIntegrityCheckneither clears an active issue nor audits.
gofmt -lover the changed Go files: clean.go vet+go testoverinternal/health,internal/store,cmd/brain: pass.- The only failure in a broader run is the pre-existing
internal/hostagent/pamverifierbuild gap (security/pam_appl.habsent — nolibpam0g-devon this box), unrelated to this change.make vet/make test-nopamcan't run as-is locally becausecmd/host-agent-realtransitively imports the PAM cgo package; this change touches no host-agent code, so the explicit non-PAM package set is the right gate here.
- Wire the
brain-db-corrupt→ Admin notification that the allowlist already covers: add anotify.healthRulesentry with its notification copy + Tier-2 "restore from backup" action route once the backup surface exists. (Belongs to the notification workstream, not this detector PR.) - The remaining unblocked detector:
#35container-restart-loop (locus D). The locus-B downstreams (#38ram-pressure,#39clock-not-synced,#40reboot-required) wait on #34'sGET /v1/health/systemtransport merging.