- Status: done
- Date: 2026-06-01
- Specs touched: docs/specs/HEALTH.md (locus-C catalog row marked built)
- Issue: #37 (closes).
Landed version-mismatch as a locus-C (brain-owned periodic) health detector. The brain reads host-agent's reported agent_version and reconciles a version-mismatch issue: raise when it differs from the version the brain expects, clear when they match. Brain-only — no host-agent change, no dependency on the GET /v1/health/system transport (#34).
Changes:
internal/health/health.go— registered theversion-mismatchdefinition inbuiltinDefinitions(): categoryversion, severityerror, Tier 2,BlocksAppsonly (not writes/users), per HEALTH.md # Version. No new Manager API — the detector reconciles through the existing genericManager.Raise/Clear, exactly like the locus-Cstore-write-failedprecedent.cmd/brain/main.go— added the detector:const expectedAgentVersion = "0.0.1-fake"— the brain-side constant the reported agent version is compared against (mirrorsinternal/hostagent.AgentVersion).agentStatusReader— a one-method consumer-side interface (SystemStatus(ctx)), satisfied by*hostclient.Client, so the check is unit-testable with a fake host-agent.checkAgentVersion(...)— readsSystemStatus, comparesAgentVersiontoexpectedAgentVersion, raises/clearsversion-mismatch, and emits the per-issue audit + notification fan-out (mirrorspullStorageHealth).versionCheckPollLoop(...)— re-runs the check on the existingMALMO_HEALTH_POLLcadence (60s default).- Wired into
main(): one check at startup (the first handshake) + the poll loop, alongside the existing storage-health poll. ReusespollCtx/pollCancel.
- HEALTH.md # Version (
version-mismatchrow): error / blocks apps / Tier 2. ✓ - HEALTH.md # Detector catalog, locus C ("brain goroutine timers over brain-owned state … the version it negotiated") and the locus-C table (
version-mismatch| host-agent vs brain version on handshake | each handshake | not the lockstep pair). Marked*(built)*in the same change. ✓ - 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 mismatch refreshes
last_checked_atevery poll without re-raising (existingraiseLockedbehavior; covered by test). ✓
- "Lockstep pair" = exact string equality against a brain-side expected version. The brain holds
expectedAgentVersion(the agent version it was built/released alongside) and raises when the reportedagent_versionis not equal to it. The issue's richer release-manifest lockstep model (RELEASE_MANIFEST.md/UPDATES.mdbrain+UI stream) is out of scope; the pair definition tightens when the release manifest lands. The constant mirrorshostagent.AgentVersionrather than importing it, so the brain stays decoupled from the agent's package and the future divergence (independently versioned binaries) is explicit — keep the two in lockstep until the release manifest replaces the constant. - "Each handshake" = each successful periodic
GET /v1/system/statusread. There is no dedicated handshake RPC; the brain already readsSystemStatusonce at startup (the first handshake) and the poll loop continues it on the 60s health cadence. - 1-shot, no debounce. A version string is a deterministic, authoritative value — it cannot flap like a threshold sample — so the detector raises/clears on the first definitive reading. (HEALTH.md's debounce default targets noisy samples; there is no noise in an equality check.) A transient unreachable host-agent neither raises nor clears, so the issue state survives a blip. (#34's debounce machinery, when it merges, need not apply here.)
- Notification allowlist entry deferred (not "undecided").
version-mismatchsurfaces 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 (v1 source allowlist) explicitly routesversion-mismatch(error) to Admin, so per spec it should push an admin notification. It is left unwired here on purpose — exactly the documented incremental-wiring pattern innotify.go(disk-full,brain-db-corrupt,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 (an Updates/System page), which is notification-UX that belongs to the notification workstream, not this detector PR.checkAgentVersionalready callsemitHealthNotifications(symmetric withpullStorageHealth), so the path is a no-op forversion-mismatchonly until thathealthRulesentry lands — then it is live with no detector change. Spec tension to flag for the maintainer:HEALTH.md# Locked decisions summarizes the notification allowlist as "storage + system criticals," butNOTIFICATIONS.md:120lists error-severity System/state issues (schema-migration-failed,version-mismatch) too — NOTIFICATIONS.md owns the taxonomy, so it's authoritative, but the HEALTH.md summary reads as if it excludes them. - No
version-mismatchactions wired. TheIssue.Actionslist is deferred project-wide (seeinternal/healthIssuedoc comment); the Tier-2 "update the lagging component" action lands with the Updates surface.
internal/health/health_test.go—TestList_VersionMismatchDefinitionpins the registered metadata (version / error / Tier 2 / blocks apps only).cmd/brain/main_test.go— drivescheckAgentVersionwith afakeStatusReader(a fake host-agent reporting a chosenagent_version):MismatchRaises— a differing version raisesversion-mismatchand writes one raised audit record (#37 Done-when).MatchClears— a matching version clears a prior mismatch and writes the clear record.MatchNoIssueIsNoop— the steady happy path raises nothing and audits nothing.SteadyMismatchRefreshesWithoutReaudit— a persistent mismatch raises once; the second poll refresheslast_checked_atwithout re-raising or re-auditing, and leavesraised_atuntouched.UnreachableLeavesStateUnchanged— an unreachable host-agent neither clears an active mismatch nor audits.
gofmt -lover the changed Go files: clean.go vet+go testoverinternal/healthandcmd/brain: pass.- Broader non-PAM run (
protocol,health,store,hostclient,hostagent,api,notify,audit,lifecycle,cmd/brain,cmd/host-agent): pass. The only failure is the pre-existinginternal/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 set is the right gate here.
- Replace
expectedAgentVersionwith the release-manifest lockstep definition onceRELEASE_MANIFEST.md/UPDATES.mdland (the issue flagged this as the place the pair definition tightens). - Wire the
version-mismatch→ Admin notification thatNOTIFICATIONS.md:120already allowlists: add anotify.healthRulesentry with its notification copy + Tier-2 action route once the Updates/System surface exists. (Belongs to the notification workstream, not this detector PR.) - The remaining unblocked locus-C/D detectors:
#36brain-db-corrupt (locus C),#35container-restart-loop (locus D). The locus-B downstreams (#38ram-pressure,#39clock-not-synced,#40reboot-required) wait on #34'sGET /v1/health/systemtransport merging.