- Status: done
- Date: 2026-05-29
- Specs touched:
LOGGING.md# v1 action vocabulary,HEALTH.md# cross-references
Closes the "audit records are count-level, not per-issue" gap noted in health-persistence.md # Known gaps. The storage-health reconciler now returns the exact issue keys that transitioned, and cmd/brain emits one audit record per raised/cleared issue targeting {kind: "health_issue", id: "<id>"} — so the Activity view answers "which issue raised?" instead of "N issues raised."
Signature changed from (raised, cleared int) to (raised, cleared []IssueKey). The reconcile loop appends the transitioning IssueKey{ID, InstanceKey} instead of incrementing a counter; both slices are sorted by (ID, InstanceKey) before return so the per-issue audit records (and tests) see a stable order, matching List()'s existing convention. IssueKey already existed (exported, used by BatchUpsertAndDelete) — no new type. The atomic-reconcile critical section, the BatchUpsertAndDelete store write, and unknown-ID dropping are unchanged.
pullStorageHealth reconciles, then calls a new emitHealthTransitions(ctx, auditor, raised, cleared) helper that emits one audit.Record per issue with Target{Kind: "health_issue", ID: k.ID} and nil metadata, replacing the two bulk {"count": N} records. The reconcile log line uses len(raised) / len(cleared). The emit loop was extracted into its own function so the headline behavior is directly testable (cmd/brain otherwise has no fake seam — pullStorageHealth takes a concrete *hostclient.Client).
LOGGING.md# v1 action vocabulary: added thehealth.issue.raised/health.issue.clearedrows that 0022 added in code but never recorded in the table (the doc's own rule: "Additions require a new entry here"). Addedhealth_issueto thetarget_kindenumeration comment and acmd/brainstorage-health-poll bullet to the Write-path call-site list.HEALTH.md# LOGGING.md cross-reference: corrected the action name fromhealth.issue_raised(underscore) to the dottedhealth.issue.raised/health.issue.clearedthat the code uses and the rest of the vocabulary follows; noted one record per issue withtarget_kind: health_issue.
internal/health: updated the two tests that asserted on the int return (RaiseAndClear,UnknownIDsAreDropped) to assert on slice length and key IDs. AddedTestApplyStorageFindings_ReturnsAffectedKeys— raises three issues in non-sorted input order and asserts the full sorted slice (three, not two, so the sort is load-bearing; with two keys Go's weak map-iteration randomization lets an unsorted impl pass too often to guard the contract), then a poll that drops two and asserts both cleared keys come back sorted. AddedTestSortIssueKeys_TieBreakOnInstanceKeyto pin the secondary InstanceKey sort directly (unreachable viaApplyStorageFindingstoday sinceFindinghas no InstanceKey field).cmd/brain(new test file):TestEmitHealthTransitions_OneRecordPerIssueasserts one record per transitioned issue with the right action constant,target_kind: health_issue,target_id,success=true, and a system actor (no identity in ctx);TestEmitHealthTransitions_NoTransitionsNoRecordspins the steady-state no-op. Backed by afakeEventStoresatisfyingaudit.EventStore.
0022 changed health.NewManager to require a HealthStore argument but never updated this api test harness, so internal/api's test package had not compiled since 0022 (health.NewManager() called with no args). Fixed to health.NewManager(nil) (in-memory, matching the "pass nil for tests that don't need persistence" convention). This unblocked the api-level health tests that exercise the reconcile path.
HEALTH.md# Persistence: "The history of raises and clears is in theaudit_eventstable" — now per-issue, so a support flow or diagnostic bundle can attribute each transition to a specific issue ID.LOGGING.md# Write path:audit.Recordis called with a populatedTarget{Kind, ID}, mirroring howuser.*/app.*records target their principal/app.target_kind: health_issueis now in the documented enumeration.- CLAUDE.md # Go code discipline (standard structured fields): the audit target uses
target_kind/target_idfor the issue.
- Box-wide issues only, in practice.
Findinghas noInstanceKeyfield yet, so every key returned today carriesInstanceKey == "". The return type and sort already handle the per-instance shape (IssueKeyround-tripsInstanceKey); when per-instance storage findings land, the auditTarget.IDwill still be the bare issue ID — surfacing the instance key in metadata is deferred to that slice. - Storage category only. Audit emission lives in
pullStorageHealth. When non-storage detectors (network/version/capacity) start raising issues through other call paths, each needs its own per-issue audit hook (or a shared emitter if one emerges).Raise/Clearalready return transition bools for that. - No notification wiring. Health raise/clear → dashboard bell is the next slice (
NOTIFICATIONS.md); it will consume the same per-issue transition signal this slice now exposes.
- Notification wiring.
NOTIFICATIONS.md— first consumer of the notifications seam: on health-issue raise (transition), enqueue a notification routed to admin users; on clear, mark resolved. The per-issue keys this slice returns are exactly the input that emitter needs.