- Status: done
- Date: 2026-05-31
- Specs touched: docs/specs/HEALTH.md, docs/specs/BRAIN_HOST_PROTOCOL.md (knock-on already written), docs/specs/DECISIONS.md (2026-05-29, referenced)
- Issue: #34 (closes). Unblocks #35 container-restart-loop, #36 brain-db-corrupt, #37 version-mismatch, #38 ram-pressure (
resources), #39 clock-not-synced (time).
Generalized the single-purpose storage health pipeline into one cross-category locus-B report, and landed service-down as the first non-storage detector exercising the per-category reconcile.
Two-axis category model (key design point). Per onel's 2026-05-31 issue clarification, the report/reconcile taxonomy is a separate axis from the brain's issue taxonomy:
protocol.HealthCategory(wire enum, pinned in full):storage | drives | services | resources | time. Partitions the locus-B report so each domain reconciles independently. Onlystorage+servicesare populated today;drives/resources/timeare reserved so #38/#39/disk-smart land as pure follow-ups.health.Category(issue display/nature enum, unchanged):storage | state | network | version | capacity.service-downis display-Categorystate(HEALTH.md # State) but report-Categoryservices.
Changes:
internal/protocol/host.go— addedHealthCategorytyped enum (5 values) +SystemHealth{checked_at, categories: map[HealthCategory][]Finding}(the GET /v1/health/system wire shape). AddedInstanceKeytoFinding(service-down is per-unit). KeptStorageHealthas the on-disk/run/malmo/health/storage.jsonboot-artifact shape (host-agent folds it into the report's storage category — the boot reporter andmalmo-storage-verifyare unchanged).internal/health/health.go—ApplyStorageFindings(StorageHealth)→ApplyFindings(category protocol.HealthCategory, findings []protocol.Finding). Clear-absent now scopes byDefinition.ReportCategory == category(replaced theiss.Categoryscope). Added twoDefinitionfields:ReportCategory protocol.HealthCategory(empty ⇒ brain-owned, never cleared by a system poll — this is what protectsstore-write-failed, which shares display-Categorystatewithservice-down) andDebounce bool. Addedservice-downdefinition (Category state, ReportCategory services, Debounce true, no block flags). All storage defs gotReportCategory: storage. Addedpending map[issueKey]intfor debounce (raise on 2nd consecutive bad sample; 1 good sample resets).last_checked_atstays refreshed every poll for active issues (existing raiseLocked behavior, preserved).cmd/brain/main.go—pullStorageHealth/storageHealthPollLoop→pullSystemHealth/systemHealthPollLoop. Pollshost.SystemHealth, iteratessh.Categories(sorted for stable audit/log order), callsApplyFindingsper category, accumulates raised/cleared, emits audit + notifications once. Addedsortimport.internal/hostclient/hostclient.go—StorageHealth()→SystemHealth()(GET /v1/health/system).internal/hostagent/agent.go— routeGET /v1/health/storage→GET /v1/health/system; handlerstorageHealth→systemHealthbuilds the category-keyed payload. AddedServiceReporterconsumer-side interface +Agent.Servicesfield. Storage category always present (empty when no source); services category present only when a reporter is wired (so the brain doesn't read "not measured" as "all up").internal/hostagent/servicehealth/servicehealth.go(new) — locus-Bservice-downdetector:systemctl is-activeoverCoreUnitsallowlist (docker,caddy,avahi-daemon,chrony,smbd; host-agent omitted — can't report on itself), oneservice-downfinding per non-active unit with the unit asinstance_key.isActiveis injectable for tests.internal/hostagent/fake.go— addedFakeServiceReporter(settable, for brain integration tests).cmd/host-agent-real/main.go— wireda.Services = servicehealth.New(); updated doc comment.
- HEALTH.md # Detector catalog "transport decision (locus B)": one
GET /v1/health/systemcarrying findings across domains;ApplyStorageFindings→ApplyFindings(category, …)with per-category clear-absent/raise-present/atomic-batch. ✓ - HEALTH.md # Cross-cutting detector policy: debounce (2 bad / 1 good; locus-B service-down debounces, storage stays 1-shot = no behavioral change); last-checked always fresh. ✓
- HEALTH.md service-down row + # State:
systemctl is-activeover core-unit allowlist, per-unit instance_key, no block flags. ✓ - BRAIN_HOST_PROTOCOL.md already describes
/v1/health/system(spec was ahead of impl — now realized). ✓ - Issue #34 clarification (2026-05-31): report category enum enumerated in full incl.
time; service-down →services. ✓
store-write-failedprotection is via the empty-ReportCategoryfilter, not display-Category. Confirmed correct: aservicespoll reconciles onlyReportCategory==servicesissues, sostore-write-failed(no ReportCategory) survives. Needs a regression test (see below).- host-agent-real cannot be built locally without
libpam0g-dev(pre-existing PAM cgo dep, unrelated to this change). The new lines (servicehealthimport +a.Services = servicehealth.New()) are trivial; verify viamake checkon a box with PAM headers or the nspawn/qemu lanes.make vet/make test-nopamboth transitively buildcmd/host-agent-real(it importspamverifier), so locally they were run as an explicit non-PAM package list instead — see Verification.
Existing tests migrated to the new seam; the TestApplyStorageFindings_* set is renamed TestApplyFindings_* to track the method rename. New coverage for the issue's done-when:
internal/health/health_test.go— migrated allApplyStorageFindings(StorageHealth{…})call sites toApplyFindings(HealthCategoryStorage, …).OnlyTouchesStorageCategory→OnlyTouchesItsReportCategory(scoping is now ReportCategory-based; themdns-downissue has no ReportCategory and survives). Added: (a)ServiceDownDebounces— no raise on the 1st bad sample, raise on the 2nd, clear on 1 good; (b)DebounceResetsOnGoodSample— an intervening good sample restarts the counter; (c)StoragePollLeavesServiceDownAlone— the locked cross-category isolation property; (d)ServicesPollLeavesStoreWriteFailedAlone— brain-owned (empty-ReportCategory) issue survives a services poll that clears service-down; (e)RefreshesLastCheckedWithoutTransition— last-checked-always-fresh.internal/protocol/host_test.go(new) —TestHealthCategoryWireValuespins the wire string of everyHealthCategoryconstant (a rename is a breaking protocol change).internal/hostagent/servicehealth/servicehealth_test.go(new) — injectedisActive: non-active unit → oneservice-downfinding (instance_key=unit, state in details); all-active → nil; one finding per down unit;New()watchesCoreUnitsand never host-agent itself.internal/hostagent/agent_test.go—/v1/health/storagetests →/v1/health/systemdecodingprotocol.SystemHealth. Storage category always present/non-nil (incl. on source error); services category present only when a reporter is wired; newServicesFromReporterasserts the seeded service-down flows through.internal/api/health_test.go—pull()now pollsSystemHealthand reconciles per category; harness wires aFakeServiceReporter. NewServiceDownDebouncesThenSurfacesdrives the first cross-category detector end to end over the production wire (debounce → raise as state-category → clear on recover).
gofmt -lover all tracked Go files: clean.go vet+go testover the explicit non-PAM package set (protocol, health, store, hostclient, hostagent, hostagent/servicehealth, hostagent/healthsource, api, cmd/brain, cmd/host-agent, cmd/malmo-storage-verify, storageverify): all pass (VET_EXIT=0,TEST_EXIT=0).make vet/make test-nopamcan't run as-is locally becausecmd/host-agent-realtransitively imports the PAM cgopamverifier(nolibpam0g-devon this box) — the explicit list excludes only the two PAM packages.- Real
systemctl is-activeover the core units on this systemd box confirmed the detector's contract:is-activeprints the state on stdout and exits non-zero for non-active units, whichsystemctlIsActivereads while ignoring the exit code. Observed:dockeractive (exit 0, skipped);caddy/chrony/smbdinactive (exit 4) → each yieldsservice-down{instance_key=<unit>, details="<unit> is inactive"}. - Adversarial multi-lens review (correctness / spec / tests / discipline, each finding refutation-verified): 7 findings raised, 5 refuted, 2 confirmed — both the same doc-comment slip in
systemHealthcalling the wire bucket the "state category" instead of "services" (the exact report-vs-display blur this change exists to prevent). Fixed atagent.goplus a sibling inservicehealth.go's package doc. No correctness or test-coverage gaps found.
- Build/run
cmd/host-agent-real(thea.Services = servicehealth.New()wiring) on a PAM-equipped box or the nspawn/qemu lane. - Downstream detectors that now plug in as pure follow-ups against the reserved categories:
#38ram-pressure (resources),#39clock-not-synced (time), disk-smart (drives),#35container-restart-loop,#36brain-db-corrupt,#37version-mismatch.