Every hour, a job wakes up and works out which of your dependencies are exposed. It doesn't diff against last hour. It re-derives the whole picture from scratch, because a detector that trusts its own previous answer inherits its own previous bugs. Idempotent by design, and far easier to reason about.
Which means the same finding, "package X is exposed to CVE-Y", exists at 09:00, and again at 10:00, and again at 11:00, unchanged, until someone fixes it. The detection is the easy half. The hard half is telling you about it once.
"Have we sent this already?" is almost the right question
The obvious move is a ledger. Before you alert, check whether you've alerted this finding before. Seen it? Stay quiet. New? Send, and write it down. A row per finding, a lookup, done.
It works until two things happen, and both of them will.
The first is retries. Sending an alert is a network call to Slack, or email, or a webhook, and network calls fail, so you retry. Now the order of two operations matters more than it looks. You write the ledger row, and you dispatch the alert, and those are two writes to two separate systems that can't be made atomic. Write the ledger first and let the dispatch fail, and you've recorded that you told someone something you never told them. That finding is now silent forever. Dispatch first and crash before the ledger write, and the next hourly run finds no record and sends it again. At-most-once and at-least-once, and you have to choose which failure you'd rather explain.
The one that hurt
The second is worse, because it wears the costume of the thing you built the ledger to stop.
A package is exposed to a Critical CVE. You alert. The row goes in the ledger. Good. A week later that same CVE lands in CISA's Known Exploited Vulnerabilities catalogue, which is the security world's way of saying this is not theoretical any more, someone is being hit with it right now.
That is the single most important alert this system will ever send about that package. And the naive ledger swallows it, because the key it deduplicates on is (tenant, package, CVE), and by that key it is a duplicate. You already told them about this CVE on this package. The row exists. Stay quiet.
I nearly shipped exactly that. What caught it was a test that walked a package from clean, to Critical, to KEV, and asserted two alerts came out rather than one. The first version produced one, and it was the wrong one to keep.
Put the reason in the key
So the ledger key isn't (tenant, package, CVE). It's (tenant, event, trigger-type). The trigger-type is the answer to "why is this worth interrupting someone", and there's more than one answer for the same package and the same CVE: first detected, severity raised, exploit published, entered KEV. Each is a distinct event that earns its own single alert. Deduplicating on the finding alone collapses all of them into the first and quietest one.
Once the key carries the reason, the retry problem gets a cleaner answer too. The row is inserted first, under a unique constraint, and that insert is the thing that decides "this is the first time". It goes in as pending, before any dispatch. The dispatcher reads pending rows and sends them, marking each one sent only when the far end confirms. A dispatch that fails leaves the row pending, and the sweeper picks it up next time without re-deciding whether the event was novel, because that decision is already made and written down. A crash between insert and dispatch leaves a pending row, which is a retry, not a lost alert.
You still can't get true exactly-once delivery. Nobody can, across a network they don't control. What you can get is exactly-once decision, made by a single unique insert, and then as many delivery attempts as it takes for the reader to see it once. The honesty is in admitting those are two separate guarantees, and only one of them is yours to make.
Why this is worth writing down
Everyone who has built a notification system has shipped the naive ledger, and most of them met the escalation bug the way I nearly did, in production, when the alert that mattered most was the one that never arrived.
If you take one thing from this: your deduplication key is a written record of what you think "the same alert" means. Go and read yours, and ask it what happens when something you already warned about gets worse.
This one keeps coming up when I compare notes with other people building agent and tooling reliability. And it's the same problem as why the fastest trigger produces no CVE seen from the other end: that post is about the signal that shows up before any CVE, this one is about delivering the alert after it lands, exactly once.
