# Notification decision table

This is the §8.1 specification for "should this interrupt someone, and what
should it say?" The executable function is `describeNotification` in
`libs/core/src/notify-router.ts`. Web Push delivery (VAPID) applies the same
function in the worker after a transactional `notifications` job.

In-app routing and Web Push share this table so a Linux PWA and a focused
browser tab cannot disagree about whether a message was news.

## Inputs

| Input | Source |
|---|---|
| Channel / global message pref | `notification_preferences.messages`: `all` \| `mentions` \| `off`. Channel mute: `channel_members.is_muted` |
| Calls / tasks / calendar prefs | booleans on `notification_preferences` |
| DND | `workspace_members.dnd_enabled` or `dnd_until > now` (plus effective status from StatusService on the client) |
| Keyword | not implemented in v1 of this table — mention parser only |
| Mention type | `@user` (handle match), `@here`, `@channel`, `@everyone` |
| Thread vs channel | `message.parentId != null` |
| Device activity | client: `document.hidden` + current route. Worker/Web Push: always treated as hidden (the tab is not looking) |
| Dedup window | notification `tag` replaces an earlier toast for the same channel/thread/call/task/event |
| Self | author / organiser / `isSelfEcho` |

## Messages

| messages pref | DM? | mentions me? | author is me? | muted / DND | Result |
|---|---|---|---|---|---|
| off | any | any | any | any | silent |
| any | any | any | yes | any | silent |
| any | any | any | no | muted or DND | silent (worker); client also skips when `effective.dndActive` |
| mentions | no | no | no | no | silent |
| mentions | no | yes | no | no | notify |
| mentions | yes | any | no | no | notify (a DM is addressed to you) |
| all | any | any | no | no | notify |

Thread replies use tag `thread-{parentId}` and open the thread route.
Channel messages use tag `channel-{channelId}`.

## Calls

| calls pref | participant? | state | already ringing? | started by me? | Result |
|---|---|---|---|---|---|
| false | any | any | any | any | silent |
| true | no | any | any | any | silent |
| true | yes | missed + `call:ended` | — | — | "Missed call" |
| true | yes | ringing | yes | — | silent (dedup) |
| true | yes | ringing | no | yes | silent |
| true | yes | ringing | no | no | urgent incoming call, silent sound (in-app ringer owns audio) |

## Tasks

Notify on: newly assigned to me (not self-created), status change if I am
assignee or creator, reassigned off me, someone else deleting a task I
created. `isSelfEcho` suppresses the actor's own mutation.

## Calendar

Notify on: newly invited (not the organiser), or `startsAt` moved. Description
edits are silent.

## Final gate (every family)

If the window is focused (`hidden === false`) and already on the exact route
the notification would open, stay silent — except `urgent` (incoming call)
and `chimeWhileLooking` (assignment still chimes, toast skipped).

Web Push skips this gate by passing `hidden: true`.

## Dedup

| Subject | `tag` |
|---|---|
| Channel message | `channel-{channelId}` |
| Thread reply | `thread-{parentId}` |
| Call | `call-{callId}` |
| Task | `task-{taskId}` |
| Event | `event-{eventId}` |

OS / browser notification centres collapse on `tag`. There is no extra
time-windowed server dedup in v1; the job is one per message insert.
