You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Third stage of the notification feature set: the first real trigger. Notifies when an update is available for a running container or an observed image. Depends on #220 and #221.
UpdateFinding rows are the event source. The subtlety that shapes this issue: RuntimeContainerScanOrchestratordeactivates and re-creates runtime update findings on every scan cycle (DeactivateSupersededRuntimeFindingsAsync → CreateRuntimeFindingAsync), so finding IDs churn constantly while the underlying fact ("web-1 can go to 1.29.0") stays the same. Deduplication therefore keys on content, not on the finding row, and the notification ledger from #220 persists that key.
Scope
Detection queries
Implemented in NotificationDispatchService, executed per enabled channel, each guarded by its own toggle.
Container updates (NotifyContainerUpdates, Kind = ContainerUpdateAvailable) — active UpdateFindings where
IsActive
ContainerSnapshotId != null
RecommendedImageVersionId != null
Type ∈ { RuntimeImageUpdate, DerivedBaseRuntimeUpdate }
TagRecommendation findings are deliberately excluded: they represent "needs review" without a concrete upgrade target and would produce noise that the user cannot act on directly.
Observed image updates (NotifyObservedImageUpdates, Kind = ObservedImageUpdateAvailable) — active UpdateFindings where
IsActive
ObservedImageId != null
RecommendedImageVersionId != null
the referenced ObservedImage.IsEnabled is true (a disabled monitored image must not notify)
Type ∈ { BaseImageUpdate, RuntimeImageUpdate }
Both queries project the data needed for the ledger row and the digest — including the container snapshot's instance/container identity, the current image reference, and the recommended tag — in a single AsNoTracking projection. Per the C# instructions, navigations are materialized into explicit locals rather than assumed to be populated; LINQ method syntax only.
The container key uses the stable (DockerInstanceId, ContainerId) pair from ContainerSnapshot rather than the snapshot ID, because snapshots are per-scan rows. Including RecommendedImageVersionId is what makes a newer recommendation a new event.
Detection performs an anti-join against NotificationRecords on (ChannelName, DedupKey) and inserts a Pending record for each unmatched event, with Subject/Details denormalized at insert time (e.g. prod / web-1: nginx:1.27.1 → 1.29.0). Detection is committed with SaveChangesAsyncbefore delivery is attempted, so a crash during delivery cannot lose events — it can at most re-send them.
Resulting behavior
Situation
Outcome
New update finding appears
One digest entry on the next dispatch run
Same finding re-created by the next scan (new row, same recommendation)
Nothing — the dedup key already has a Sent record
Recommendation moves on (1.29.0 → 1.29.1)
New key → new record → notified again
Finding deactivated without successor (user updated the container)
Nothing sent; "resolved" notifications are explicitly out of scope for v1
Deactivate-and-supersede within one scan
Invisible to the dispatcher, which only sees the committed end state
Observed image disabled
Its findings never notify
The existing Sent record is intentionally not re-pointed at the newer finding ID: Subject is already denormalized, so nothing is gained by the extra write.
Digest builder
One aggregated message per channel per dispatch run, not one message per finding — a scan that discovers twelve updates produces one notification, not twelve.
Title summarizes counts, e.g. DockerUpdateGuard: 3 updates available
Summary is a plain-text rendering used by the ntfy/Gotify formats
Items are capped at MaxItemsPerNotification; the remainder is reported through TruncatedItemCount and an "and N more" line in the summary
Ordering is deterministic (kind, then subject) so digests are stable and testable
On a successful send, every included record is set to Sent with SentAtUtc and a shared DispatchBatchId, then saved. Failure handling beyond a simple retry-next-run is added in #224.
Testing
Extend src/Tests/DockerUpdateGuard.Tests/NotificationDispatchServiceTests.cs (SQLite-backed context as in RuntimeContainerScanOrchestratorTests, fake INotificationChannel recording sends, TestOptionsMonitor, TestLogger):
new container update finding → exactly one item in exactly one digest
row churn: delete and re-create the finding with the same recommendation between two dispatch runs → no second notification (the regression this design exists for)
changed RecommendedImageVersionId → second notification
TagRecommendation finding → never notified
finding without RecommendedImageVersionId → never notified
observed image finding notified; the same finding with IsEnabled = false → not notified
NotifyContainerUpdates = false suppresses only the container scope, NotifyObservedImageUpdates = false only the observed scope
more than MaxItemsPerNotification events → digest capped, TruncatedItemCount correct, all records still marked Sent
Third stage of the notification feature set: the first real trigger. Notifies when an update is available for a running container or an observed image. Depends on #220 and #221.
Full design: notification concept
Context
UpdateFindingrows are the event source. The subtlety that shapes this issue:RuntimeContainerScanOrchestratordeactivates and re-creates runtime update findings on every scan cycle (DeactivateSupersededRuntimeFindingsAsync→CreateRuntimeFindingAsync), so finding IDs churn constantly while the underlying fact ("web-1 can go to 1.29.0") stays the same. Deduplication therefore keys on content, not on the finding row, and the notification ledger from #220 persists that key.Scope
Detection queries
Implemented in
NotificationDispatchService, executed per enabled channel, each guarded by its own toggle.Container updates (
NotifyContainerUpdates,Kind = ContainerUpdateAvailable) — activeUpdateFindingswhereIsActiveContainerSnapshotId != nullRecommendedImageVersionId != nullType∈ {RuntimeImageUpdate,DerivedBaseRuntimeUpdate}TagRecommendationfindings are deliberately excluded: they represent "needs review" without a concrete upgrade target and would produce noise that the user cannot act on directly.Observed image updates (
NotifyObservedImageUpdates,Kind = ObservedImageUpdateAvailable) — activeUpdateFindingswhereIsActiveObservedImageId != nullRecommendedImageVersionId != nullObservedImage.IsEnabledis true (a disabled monitored image must not notify)Type∈ {BaseImageUpdate,RuntimeImageUpdate}Both queries project the data needed for the ledger row and the digest — including the container snapshot's instance/container identity, the current image reference, and the recommended tag — in a single
AsNoTrackingprojection. Per the C# instructions, navigations are materialized into explicit locals rather than assumed to be populated; LINQ method syntax only.Dedup keys
The container key uses the stable
(DockerInstanceId, ContainerId)pair fromContainerSnapshotrather than the snapshot ID, because snapshots are per-scan rows. IncludingRecommendedImageVersionIdis what makes a newer recommendation a new event.Detection performs an anti-join against
NotificationRecordson(ChannelName, DedupKey)and inserts aPendingrecord for each unmatched event, withSubject/Detailsdenormalized at insert time (e.g.prod / web-1: nginx:1.27.1 → 1.29.0). Detection is committed withSaveChangesAsyncbefore delivery is attempted, so a crash during delivery cannot lose events — it can at most re-send them.Resulting behavior
SentrecordThe existing
Sentrecord is intentionally not re-pointed at the newer finding ID:Subjectis already denormalized, so nothing is gained by the extra write.Digest builder
One aggregated message per channel per dispatch run, not one message per finding — a scan that discovers twelve updates produces one notification, not twelve.
DockerUpdateGuard: 3 updates availableSummaryis a plain-text rendering used by the ntfy/Gotify formatsMaxItemsPerNotification; the remainder is reported throughTruncatedItemCountand an "and N more" line in the summaryOn a successful send, every included record is set to
SentwithSentAtUtcand a sharedDispatchBatchId, then saved. Failure handling beyond a simple retry-next-run is added in #224.Testing
Extend
src/Tests/DockerUpdateGuard.Tests/NotificationDispatchServiceTests.cs(SQLite-backed context as inRuntimeContainerScanOrchestratorTests, fakeINotificationChannelrecording sends,TestOptionsMonitor,TestLogger):RecommendedImageVersionId→ second notificationTagRecommendationfinding → never notifiedRecommendedImageVersionId→ never notifiedIsEnabled = false→ not notifiedNotifyContainerUpdates = falsesuppresses only the container scope,NotifyObservedImageUpdates = falseonly the observed scopeMaxItemsPerNotificationevents → digest capped,TruncatedItemCountcorrect, all records still markedSentAcceptance criteria
TagRecommendationfindings and findings without a recommendation never notifyMaxItemsPerNotification, and reports the truncated countreihitsu-format ./clean, build without new analyzer findings, all tests greenOut of scope