Notification Classes Reference
Every notification PrimeCal creates is run through TriageEngineService (backend-nestjs/src/notifications/triage-engine.service.ts) exactly once, synchronously, inside NotificationsService.publish() — before the NotificationMessage row is even saved. This page documents the five priority classes it can produce, the default routing decision for each, the inbox-rule actions that can override that default, and the full notification lifecycle with the entity/column that backs each stage.
This is a deterministic, rules-based engine — there is no ML/LLM scoring involved.
The Five Classes
NotificationClass (backend-nestjs/src/entities/notification-message.entity.ts):
| Wire value | Weight | Default routing | ackRequired | Plain-language meaning |
|---|---|---|---|---|
critical | 100 | interrupt_now | true | Hard commitments — always interrupts immediately, and floor for classification never goes lower once assigned. |
time_critical | 80 | interrupt_now | false | Real time pressure — interrupts as the moment approaches. |
coordination | 60 | interrupt_now | false | Needs a specific responsible person to act. |
operational | 40 | hold_for_gap | false | Day-to-day updates — held for a natural gap or batched. |
fyi | 10 | batch_digest | false | Good to know — always batched, never interrupts. |
ackRequired is true if and only if the final resolved class is critical (checked once, after all adjustments below).
Routing Decisions
NotificationRoutingDecision (same entity file):
| Wire value | Meaning |
|---|---|
interrupt_now | Delivered immediately on every enabled channel, subject only to per-channel fallback ordering. |
hold_for_gap | Delivery to non-in-app channels is delayed ~15 minutes (NotificationDelivery.status = 'scheduled', released by the digest queue's delivery-release job). |
batch_digest | Folded into the recipient's next hourly/daily digest email instead of sent standalone, if the recipient's own digest preference is immediate (an explicit non-immediate preference is left as-is). |
Critical and Time-critical are hard-coded exceptions in NotificationsService.enqueueDeliveries(): they are never held or batched regardless of the recipient's digest/quiet-hours preference or any hold_for_gap/batch_into_digest rule override.
How the Default Class Is Resolved
TriageEngineService.evaluate() runs these steps in order for every recipient of every publish() call:
-
resolveClass()— checksEVENT_TYPE_CLASS_OVERRIDES(an explicit per-eventTypemap) first, then falls back toCATEGORY_CLASS_FALLBACK(per notification category), then defaults tofyiif neither matches.Explicit event-type overrides (partial — see the source for the full, evolving list):
Event type Class event.remindertime_criticalevent.canceled/event.deletedcoordinationreservation.duetime_criticalreservation.payment.failedcriticalreservation.created/.updated/.no_showoperationalsystem.broadcastcriticaltask.assignment.accepted/.bounced,task.routine.assignedcoordinationautomation.executedfyiautomation.failedoperationalCategory fallback (used when no explicit override exists for the event type):
system → critical,event → time_critical,task → coordination,calendar/reservation/organisation → operational,automation → fyi. -
applyHardnessAdjustment()— if the triggeringEvent.isHardCommitmentistrue, the class is floored attime_critical(never lower). If it's explicitlyfalseand the resolved class wascritical, it's stepped down totime_critical.isHardCommitmentis a nullable boolean column added directly onevents(see theAddNotificationCenterV2migration);null/unset means no adjustment either way. -
applyFeedbackTuning()— if the recipient has previously sent "too much"/"too quiet" feedback for this class (UserNotificationSettings.feedbackTuning[class], a signed integer), the class steps one tier up or down the ladderfyi → operational → coordination → time_critical → critical. Never applied tocritical. -
scorePriority()— starting from the class weight above: +15 if a recognized time field indata(startsAt,startAt,startTime,dueDate,eventDate, orscheduledAt) is within 2 hours of now (adjusted by a static, zero-geocodingtravelBufferMinutesfrom aUserNotificationCalendarOverriderow, if one exists for that calendar/event type), +5 if within 24 hours, else +0; +10 if the recipient is the responsible party (data.assigneeId/data.ownerIdmatches the recipient); -15 if they're explicitly not the responsible party on acoordinationnotification (bystander). Clamped to 0–100. -
applyBusyStateHold()— if the recipient is in an active Focus session (checked live viaFocusSessionService),operational/fyinotifications (andcoordinationnotifications where the recipient isn't the responsible party) are routed tohold_for_gapinstead of their normal default, even though the class itself doesn't change.critical/time_critical, andcoordinationwhere the recipient is responsible, still interrupt. -
applyFeedbackRouting()— feedback tuning also directly forces the routing decision for this call (too_much→batch_digest,too_quiet→interrupt_now), independent of the class-ladder shift in step 3. Never applied tocritical.
Overriding the Default: Inbox-Rule Actions
A NotificationInboxRule (notification-rules.service.ts, NotificationRulesService.applyRuleActions()) can override the engine's output per-recipient via these action types, applied to evaluation.triageOverride and merged on top of the triage engine's result in publish():
Action type | Payload | Effect |
|---|---|---|
set_priority | { class: NotificationClass } | Forces notificationClass regardless of the engine's default. |
route_channels | { channels: NotificationChannelType[] } | Forces the exact delivery channel list, overriding the recipient's own channel preferences (not a triage override — sets evaluation.forceChannels directly). |
hold_for_gap | none | Forces routingDecision: 'hold_for_gap'. |
batch_into_digest | { cadence?: 'hourly' | 'daily' } | Forces routingDecision: 'batch_digest'; the cadence itself is recorded on metadata.evaluation.batchCadence for audit visibility only — the actual per-channel digest cadence is still the recipient's own UserNotificationPreference.digest setting. |
escalate_after | { minutes: number, target: 'group_elevated' | 'org_admin', targetId: number } | Stores escalation config on metadata.evaluation.escalation; enforced later by the notifications-escalation-sweep cron (every 5 minutes) — see Lifecycle below. Not yet exposed in the frontend's Advanced Rules builder UI; set it via the API or an MCP-connected agent. |
These sit alongside the original rule actions that don't touch triage: suppress_notification, suppress_channels, archive, mark_read, mark_unread, mute_thread — see the Notifications API for the full rule/condition shape.
Mode Packs (NotificationModePackService) are just a convenience layer on top of this same mechanism: selecting focus_adhd or work_store pre-seeds a handful of global rules using exactly these action types (tagged with packOwned so switching packs replaces only that pack's rules, never a user's own).
Lifecycle States
| State | Backing entity / column | Set by |
|---|---|---|
| Created | NotificationMessage row inserted; createdAt | NotificationsService.publish(), once per non-suppressed recipient |
| Triaged | NotificationMessage.notificationClass, .priorityScore, .routingDecision, .ackRequired | Set at creation time from TriageEngineService.evaluate(), merged with any rule triageOverride — happens in the same publish() call, never as a separate step |
| Scheduled | NotificationDelivery.status = 'scheduled', .metadata.releaseAt / .metadata.digest / .metadata.quietUntil | NotificationsService.enqueueDeliveries() when a digest cadence, quiet-hours window, or hold_for_gap routing delays a non-in-app channel; a delayed delivery-release Bull job is queued on the digest queue |
| Delivered | NotificationDelivery.status = 'sent', .sentAt | In-app deliveries are marked sent immediately; other channels are marked sent by NotificationsDigestProcessor once NotificationChannelRegistry.send() succeeds (or 'failed'/'skipped' on error/NotificationChannelSkipError — also real values of this same column) |
| Seen | NotificationMessage.isRead, .readAt | PATCH /:id/read, POST /read-all, or implicitly by POST /:id/act (any inline action marks the message read as a side effect) |
| Acted | NotificationAction row (actionType, actionPayload, actedAt) | POST /api/notifications/:id/act → NotificationsService.recordAction() |
| Expired | NotificationMessage.expiresAt reached | Computed at creation (computeExpiresAt(): ~1h grace after the referenced time for time-bound Critical/Time-critical items, 24h default for other time-bound items, 7 days otherwise); enforced by the notifications-expiry-sweep cron (every 15 min), which currently only sweeps unarchived critical/time_critical messages — it archives the message and fires a notification.recovery follow-up rather than deleting anything |
| Suppressed | Two distinct mechanisms — see below | Rule evaluation in NotificationRulesService.evaluateNotification() |
The two forms of "suppressed"
- Full suppression — an inbox rule's
suppress_notificationaction setsevaluation.suppressed = true.publish()continues for that recipient before anyNotificationMessagerow is created at all. There is nothing to query afterward for that recipient/event — it simply never existed. - Silent archival — a scope mute (
NotificationScopeMute) or thread mute (NotificationThreadState.isMuted) matching the notification's calendar/organisation/reservation/thread causes the message to still be created, but immediatelyarchived = true,isRead = true, withmetadata.evaluation.silent = true(andscopeMuted/threadMutedflags alongside it). It's excluded from the real-time "new notification" socket push, but it does exist and can still be queried viaGET /api/notifications?archived=true.
Not currently wired up
NotificationThreadState.lastReadAtexists as a column but no code path inNotificationThreadsServicecurrently sets it — thread-level "seen" state isn't tracked separately from the per-messageisReadflag as of this writing.- The escalation sweep only escalates messages carrying
metadata.evaluation.escalation(from theescalate_afterrule action) that are still unread after the configured number of minutes; it fires anotification.escalatednotification to the resolved target and stampsNotificationMessage.escalatedAtso it never double-fires.
Related
- Trigger Reference —
notification.dispatchedfires an automation rule for any notification, usingnotification.type/notification.channel/notification.data.*condition fields - Notifications API — routes, DTOs, and example calls for everything referenced above
- Notification Preferences (User Guide) — the same concepts in end-user language