Skip to content

Suppression

The decisions not to send. This is the part of the service where correct and broken look identical from outside: the sender does not wait, the recipient cannot know what did not arrive, and nothing downstream is code.

Three independent mechanisms, in the order they apply: deduplication in the engine, then the participant's kinds, then the daily cap.


REQ-0033 — a duplicate is one rule, one person, one community, one period

The key is <rule>:<user>:<community>:<scope>. A missing community is an empty segment rather than a dropped one, so rule:user::2026-08-15 cannot collide with a community literally named the empty string.

Everything in the key widens the suppression by its absence. The user is in it because dedup is per person: the same rule firing for two members of a community sends two notifications.

REQ-0034 — the window names the period, and the sender's date decides its granularity

definition.dedup_window maps to a scope segment:

Window Scope
always a fresh UUID — never suppressed
once, one the literal once — suppressed for ever
hourly facts.hour, or the current UTC hour
daily, weekly, monthly, yearly the sender's time/date/week/period
anything else (e.g. two_weeks) the same as above
nothing to fall back on the current UTC month

The window name does not set the granularity. A rule declared monthly that receives time: 2026-08-15 deduplicates per day, because the scope is the string it was given. What normally keeps the two consistent is REQ-0024, which only selects the rule when the two agree — and which also makes always, once and two_weeks unreachable through ingest.

REQ-0035 — the database is what refuses the duplicate

The nudges_log.dedup_key unique constraint. The engine inserts and catches the violation, so two workers racing on the same event both compute the same key and exactly one insert survives. It also looks the key up before inserting: a key that is already in the table is refused without provoking the database, because every collision is an ERROR line in the PostgreSQL log (1,429 a day in staging, September 2026, while the digital twin re-sent the same anomalies every five minutes). The lookup is an optimisation; the constraint remains the arbiter.

The duplicate is recorded as a suppressed_dedup audit row naming the key, and no notification is written. That row is the only evidence anywhere in the platform that a duplicate was stopped. It is written once per suppressed key — its own dedup_key is suppressed:<key> — and every later repeat of the same key bumps details.repeats and details.last_seen_at on that row instead of adding one. A sender's retry cadence must not become audit-log growth: the stuck scheduler of 2026-07-20 → 09-07 left 141k rows.

The branch is entered by matching the literal uq_nudges_dedup_key in the driver's error text. PostgreSQL puts the constraint name there and SQLite does not, so a rename in a migration would turn every duplicate into an unhandled 500 with nothing failing. The suite pins the literal against the model and the migration, and simulates PostgreSQL's wording for that one constraint — see ADR-0003.

REQ-0036 — a participant receives a kind only if it is in their enabled list

A rule declares definition.kind; the orchestrator suppresses the notification when that kind is not in the participant's enabled list, recording kind_disabled on every job and marking the notification suppressed. A kind the catalogue (active_kinds.yaml) does not list is in nobody's enabled list, so it is suppressed too: the catalogue is an allow-list, and the ten-odd shipped kinds that are not in it (kpi_conditions, static_message, price_*, ...) are not delivered until catalogued.

Two things escape this:

  • a rule with no kind, or a blank one, is matched against nothing and always delivered;
  • the operator kinds in ALWAYS_DELIVERED_KINDS (orchestrator.py) are always delivered. Today that is only grid_risk_report, the DSO grid risk report: an operator report, not a participant preference, so it is deliberately kept out of the catalogue.

A stored consent naming a kind the catalogue no longer lists is dropped rather than carried forward.

REQ-0037 — some kinds may not be refused, and a community preference wins

A catalogue entry with editable: false is added back to the enabled list whatever the participant chose, and reported as enabled. Weather alerts (extr_event) are the one such kind: a civil-protection alert is not a preference.

Where a participant has both a community-scoped preference row and a generic one, the community-scoped row is used — community_id IS NULL sorts last. (The same pair of rows breaks language resolution: REQ-0029.)

REQ-0038 — a participant who has chosen nothing receives everything

Opting in is the default. No preference row, no consents, or a consents value of the wrong shape all mean every active kind — so a corrupted consents blob silently re-enables what a participant switched off.

The cap for a participant with no row is a literal 3 in the orchestrator, not MAX_PER_DAY_DEFAULT; that setting only fills in the column for seeded rows. The two values agree today. Filed as #36.

REQ-0039 — the daily cap counts the participant's notifications sent today, on any channel

sent_today < max_per_day, where the count is the participant's notifications with at least one delivery_log row that is sent and whose sent_at is today, whatever the channel. A delivery is attributed through its nudge's user_id. It counts notifications, not deliveries, because the promise on /preferences is "at most N a day": one that reached both web and email counts once, and an email-only one counts too. Until #37 it counted web deliveries by destination prefix, so email was unbounded.

Three narrowings, each a way for the cap to be wrong:

  • a suppressed or failed attempt does not consume the allowance;
  • with a community, only that community's notifications count, so a participant in two communities has two allowances; without one, all of the participant's notifications count;
  • email-only ingest (REQ-0014) is not capped. Grid alerts to an operator inbox have no participant and no preference, and suppressing one past a count is a safety risk (requester, 2026-09-29).

The participant-facing bound is 1..10 (PUT /preferences/me). The database has no such constraint, so a seed can write 0, which silences that participant completely.

REQ-0040 — over the cap, every job is logged and the notification is marked

One suppressed delivery-log row per job that would have been sent, with rate_limited and no sent_at, and the notification's status becomes suppressed rather than staying pending — otherwise a participant's list would show an undelivered message as though it were still on its way.

REQ-0041 — a notification reports the best outcome of its deliveries

One sent makes it sent, however many channels failed. All-suppressed is suppressed. Anything else is failed.

So a participant whose email bounced but whose push arrived sees nothing wrong — which is right — and an operator reading notifications.status cannot see the bounce either. The per-channel truth is in delivery_log.