- Status: done
- Date: 2026-05-29
- Specs touched: docs/specs/NOTIFICATIONS.md, docs/specs/NEXT.md
Implemented the brain-side of per-user, per-category mute (NOTIFICATIONS.md # Configuration) — the last v1 notification-config item. Backend only (table + store filter + API + tests); the settings-toggle UI is the follow-up slice, mirroring how the read surface (notification-read-surface.md) landed before its bell UI (notification-web-ui.md).
notification_mutestable (internal/store/store.go). Keyed(user_id, category)— the PK alone fully models the mute. Presence of a row means the category is muted for that user; absence means on — so a new user has no rows and sees everything ("everything on by default"). Unmute is aDELETE.ON DELETE CASCADEonuser_idcleans up when a user is deleted. Created in themigrate()DDL block — a fresh additive table, noALTER/backfill needed.- Mute is a read-time filter, never emit-time suppression (
internal/store/notify.go). A box-wideadmins/membersnotification is one row shared by many recipients, so it can't be withheld per-user at write time. A newnotificationMuteClause()(AND n.category NOT IN (SELECT category FROM notification_mutes WHERE user_id = ?)) is threaded into the three aggregate read queries —ListNotificationsForRecipient,CountUnreadNotifications, andMarkAllNotificationsRead. The per-idGetNotificationpath (used by mark-read/dismiss) is deliberately mute-agnostic, so a user can still act on a specific notification in a muted category (e.g. one they read before muting). - Mark-all-read honors the mute filter. A muted category is left untouched by mark-all, so unmuting later reveals its notifications in their true unread state rather than as already-read rows the user never saw — keeping the three aggregate read queries consistent.
- Store CRUD (
internal/store/notify.go):MuteNotificationCategory(idempotent —ON CONFLICT DO NOTHING, a repeat mute is a no-op),UnmuteNotificationCategory(idempotent no-op when absent),ListMutedCategories(sorted). - Full category taxonomy in
notify(internal/notify/notify.go). The mute surface validates against the completestorage | system | updates | security | account | appset — a user can mute a category before its source exists (the spec'supdates-chatter example) — so the four previously-undefinedCategoryconstants plus aCategoriesslice andValidCategoryhelper now live innotify, the routing/derivation layer. Onlystorage/systemhave producers today; the rest light up as their sources land. - Wire surface (
internal/api/notifications.go):GET /api/v1/notifications/mutes(the caller's muted categories),PUT/DELETE /api/v1/notifications/mutes/{category}(mute/unmute, idempotent, 422 on an unknown category). Mute/unmute publishnotification.updatedon the SSE bus (mirroring the read-state handlers) so the caller's other tabs refetch the badge. Mutating a mute is not audited — a personal view preference, not an elevation-class action (CLAUDE.md).
Tests: internal/store covers hide-from-list/count, unmute-restores, per-user isolation, idempotency + sorted listing, mark-all-skips-muted (with unmute revealing the still-unread row), and audience-independence (a member muting drops their members-broadcast rows). internal/api round-trips the wire surface (mute hides + GET reflects + DELETE restores), rejects unknown categories (422 on PUT and DELETE), pins per-user isolation, and extends the auth-fence table to the three new routes. internal/notify pins ValidCategory over the full taxonomy. Full suite green with -race (excluding the PAM-cgo host-agent/pamverifier packages, which don't build in the sandbox).
NOTIFICATIONS.md# Configuration — realized: per-user, per-category mute; everything on by default; presence-row model; read-time filter on list/count/mark-all. Added a "Delivery (as implemented)" note in the same change.NOTIFICATIONS.md# Knock-ons (BRAIN_UI_PROTOCOL.md) — the/api/v1/notificationsfamily's "per-category mute" endpoints are now real (GETlist,PUT/DELETEtoggle).NOTIFICATIONS.md# Locked decisions — "Per-user, per-category mute; everything on by default. No quiet hours / severity tuning in v1." exercised exactly; severity stays non-tunable.- Reuses the locked audience model and
notification_readsread-state join from notification-read-surface.md/notification-clears-transparency.md unchanged.
- Mute hides all severities, including criticals. Muting
storagealso hides adata-drive-readonlycritical. Spec-faithful (the spec scopes mute to a category, defaults everything on, and the example is info chatter) and the user's explicit opt-in, but a "criticals always ring through" carve-out is plausible later — surfaced as an open question inNEXT.md# Observability, not decided here. - No settings UI yet. This is the backend half; the settings-toggle UI (
WEB_UI.md) is the next slice. The mute API is usable over the wire today but has no dashboard control. - No producer/emit change.
internal/notifyandcmd/brainemit paths are unchanged — mute is purely a read-side filter plus the CRUD surface.
- Settings UI toggle for mute (
WEB_UI.md) — a per-category toggle list readingGET /mutesand callingPUT/DELETE, with the bell badge/list reflecting it live over SSE. - Retention / pruning for
notifications+notification_reads(NEXT.md# Observability) — the last v1 notification item; capped count/age, resolved-rows-first a candidate policy. - Out of family but adjacent: SMART/
disk-fulldetectors, update-outcome and security-audit notification sources (NOTIFICATIONS.md# The notification list) — each new source makes its category mute-able with no further work here.