Count unread notifications by the user's push rules at read time, from the start of their join¶
- Status: accepted
- Date: 2026-10-22
Technical Story: conformance audit 2026-10-17, finding R15 (unread counts ignore push-rule notify/highlight) and concern 1 (unread counts include pre-join messages)
Context and Problem Statement¶
sync::count_unread_by_thread backs every unread count: /sync unread_notifications and
unread_thread_notifications, the sliding-sync counts and by_notification_count sort, and the
Push Gateway badge. It counted every m.room.message and m.room.encrypted event as a
notification and every event whose m.mentions named the user as a highlight. The spec says
"Notifications are determined by the push rules which apply to an event", and highlight_count is
"The number of unread notifications for this room with the highlight flag set". A muted room
still counted, a keyword rule never highlighted, and a highlight was counted even when the rules
did not notify.
It also counted events sent before the user joined. Once the user's own rules decide what counts,
that is a disclosure: a content rule counted over history the user may not see (history
visibility joined or invited) reveals whether those messages contain a word.
Considered Options¶
- Evaluate the user's push rules for each unread event inside the existing count walk
- Count the
notificationsrows that delivery records at ingest - Record push actions per (user, event) at ingest in a new table, as Synapse does
Decision Outcome¶
Chosen option: evaluate in the existing walk. count_unread_by_thread asks a
sync::UnreadEventClassifier about every unread event. The homeserver's
PushRuleUnreadClassifier loads the user's rules once (ADR-0204), computes each room's facts
(power levels, member count and the user's display name, through the same push_room_facts
helpers delivery uses) and evaluates push::evaluate_push_rules. The first room it meets gathers
the facts for all of the user's rooms, from one walk of each table it reads (ADR-0207).
An event counts as a notification when the result notifies, and as a highlight when it notifies
with the highlight tweak. Callers build one classifier per request: /sync, sliding sync (its
counts and sort), and the badge for each push delivery.
Only events after the start of the user's current join are counted (current_join_start): the
earliest m.room.member join of theirs with no later leave, ban, invite or knock. A join re-sent
for a profile change does not restart the span. A user who is not joined counts nothing.
Stream order alone is not enough: a backfilled event from before the join is stored, and ordered,
after it. Every counted event therefore also passes sync::HistoryVisibility::can_see, the filter
the timeline uses, judged by the state at the event (the classifier's visible). /sync and
sliding sync lend the request's own filter, so its indexes are built once per request. Only
accepted events count; a rejected or soft-failed event is never served (ADR-0207 records the
same for the badge).
Positive Consequences¶
- Every ingest path is covered, because nothing is recorded at ingest.
- A rule change applies at once to what is unread: muting a room clears its count.
- No migration, and the counting path stays the one walk the sync module requires.
Negative Consequences¶
- Events are judged against the room's current state (power levels, member count, display name), not the state when they arrived.
- Each
/syncevaluates the rules for every unread event. That costs one evaluation per unread event, with the user's rules bounded by ADR-0204, underruntime.mutex. - Events from before the user's join no longer count, even in a room whose history the user can read.
Pros and Cons of the Options¶
Count the notifications rows¶
- Good, because evaluation would happen once per event and counting would be cheap.
- Bad, because a user keeps at most
max_notifications_retained_per_userrows (default 1000), oldest first across all rooms, so a busy room would erase a quiet room's unread count. - Bad, because rows exist only for events that reached the delivery path, and a rule change would not apply to events already recorded.
A per-(user, event) push-actions table¶
- Good, because it judges each event against the state when it arrived.
- Bad, because it needs a migration and one row per local member per event, plus Synapse-style rotation into summaries to keep counting cheap.