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 onlygrid_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.