diff --git a/docs/adr/0081-store-machine-health-finding-mutes-in-their-own-state-file.md b/docs/adr/0081-store-machine-health-finding-mutes-in-their-own-state-file.md new file mode 100644 index 00000000000..a4e17aff1cb --- /dev/null +++ b/docs/adr/0081-store-machine-health-finding-mutes-in-their-own-state-file.md @@ -0,0 +1,49 @@ +# Store machine-health finding mutes in their own state file + +- Status: accepted +- Date: 2026-10-21 + +## Context + +The machine-health audit is gaining checks that list items (startup entries, extensions, listeners, +large folders). The owner needs to mark an item as expected so it stays listed but stops raising the +check's severity, and to make that pick on the interactive decision page that each run builds. + +A mute has to outlive the run that created it, so it needs a stable name for an item across runs, a +stored place to live, and a way to tie a pick on a page back to the item that page showed. + +The first design put mutes under `check_overrides` inside `approvals.json` at schema 1.0. A stress +test rejected that: an older plugin build reading an `approvals.json` with an unknown key either +FAILs `setup check` or strips the key on its next write, so a mute could break setup or vanish during +an update window. + +## Decision + +- **Mutes live in `/state/mutes.json`**, validated by a new + `catalog/schemas/mutes.schema.json` at `schema_version` `"1.0"`. Its shape is + `checks..muted_findings[]`, each entry `{ key, label, reason, added_at, run_id }`, with + `key` and `added_at` required and no other properties allowed. +- **`approvals.json` and `approvals.schema.json` are unchanged**, byte for byte. Remediation + approvals stay there; mutes never go there. +- **A finding item is named by a FindingKey**: `:`, matching + `^[a-z-]{1,16}:[0-9a-f]{16}$`. The kinds are `startup`, `task`, `folder`, `app`, `ext`, + `listener`, `exclusion`, `detection` and `firewall`, each with a fixed list of identity fields. A + listener whose owner cannot be resolved gets no key and cannot be muted. +- **A pick resolves only against the map of the run that built the page.** Each run writes + `/views/checkup-.ids.json`, mapping page row ids to an action and a + FindingKey. The page's copied payload carries a builder-assigned `run: ` line, and the + resolver opens only the map that line names. A payload with no `run:` line, or one naming a map + that is gone, is refused whole. The newest 12 runs' maps are kept. + +## Consequences + +- The FindingKey format and each kind's identity fields are one-way: changing either orphans every + stored mute. A change ships with a migration or a new kind name. +- A downgraded plugin has no reader for `mutes.json`, so muted items raise severity again until the + upgrade. Neither file FAILs and nothing is stripped. The CHANGELOG fragment for the release that + adds `mutes.json` says so. +- Checks never read `mutes.json`. The orchestrator reads it and passes the muted keys to each check + as an argument. +- A payload pasted after its run has been pruned (more than 12 runs later) cannot be resolved; the + owner re-picks on a newer page.