Communication — Core concepts
Artifacts and "grain"
"Grain" = the level of detail represented by one record (what one record stands for).
| Artifact | Store | Grain | Role |
|---|---|---|---|
| NotificationJob | Azure Table | 1 notification event | Input: "there is work to send". |
| NotificationScheduler | SQL | 1 pending job | Queue row for Scheduler to pick up. |
| MessagingJob + Blob | SQL + Blob | 1 large payload | Coordinates campaign/report (large content lives in Blob). |
| UserMessage | Azure Table | 1 recipient | In-app message; inbox in the app. |
| OutgoingMessage | Azure Table | 1 recipient × 1 channel | Message sent through provider; carries delivery state. |
| SmsUsage | Azure SQL | 1 accepted SMS provider | SMS billing ledger by BusinessUnit (billing). |
Send pipeline: three steps
| Step | What it does |
|---|---|
| Scheduler | Scans Ready work; evaluates Notification Settings (is this type enabled, who receives it). |
| Generator | Renders templates + resolves recipients; creates UserMessage (in-app) and/or OutgoingMessage (external channel). |
| Distributor | Sends for real through SendGrid/Twilio; "claim before send". |
| Webhook & Tracker | Updates state (delivered/bounce/opened) back to OutgoingMessage. |
Four core principles
Every Communication decision follows from 4 principles (shared with automation):
Identity keys instead of random keys — each occurrence has exactly one deterministic key; every downstream key (OutgoingMessage, etc.) is derived from it. Duplicate events/retries produce the same key → no duplicates.
Ensure*instead ofCreate*— every artifact creation operation means "ensure it exists": already exists and compatible → always success (idempotent); content differs (InputHashdiffers) → conflict, stop automatic sending, report support. NeverUpsert Replace— avoid accidentally sending again.Claim before send — Distributor must move
OutgoingMessagetoSendingwith a conditional update before calling the provider. Duplicate messages that see the row alreadySending/Sentexit — no duplicate emails.Bounded reconciliation instead of outbox — no complex outbox; each provider declares a bounded scan-repair path (watermark, date window, batch) at low frequency to repair rare issues.
⚠️ Exception to no-replace:
EntityHistory(audit) may upsert InsertOrReplace by deterministic key — itsRowKeyis not externally referenced, overwriting on retry is harmless.NotificationJobkeeps query-before-append.
Template
Message content is a template with {{...}} placeholders (e.g. {{INCIDENT_DETAILS_LINK}}); Generator fills real data at render time. A template has key + version ("immutable routing fields" — not changed mid-send).
Immutable vs mutable fields
When Ensure* matches an existing artifact, it distinguishes:
- Immutable (message identity): source coordinates, event type, recipient identity, delivery method, template key/version, run key, automation coordinates. Mismatch → conflict, stop send.
- Mutable (safe to change): retry counters, scheduler coordinates, provider status, webhook timestamps, bounded diagnostics.
Campaign delivery: idempotency per recipient × channel
- Each campaign
OutgoingMessagehas a deterministic key from (Campaign, stable recipient, normalized destination, delivery method) — retry resolves to exactly one artifact; new keys do not overwrite old random-key rows (still readable). - A recipient's email and SMS are two independent artifacts, completeness evaluated per channel — a channel that exists does not let a missing channel be skipped; a successful channel is not recreated. Retry only creates the missing part.
- Ensure does not overwrite provider-progress (claim, provider id, timestamps, terminal state); retry hitting an immutable conflict → fail closed (stop automatic sending) + bounded diagnostic.
- A row already persisted but not yet queued → generation retry repairs it (publish/restore exactly one work item) within the campaign partition scope; a row already delivered to the provider is not published again.
In-app is an independent campaign channel
- Campaign supports an optional In-app channel, creating
UserMessageindependent ofOutgoingMessage(Email/SMS); legacy payload without the field → In-app off; disabling In-app does not disable the provider channel. UserMessageis created only when the target resolves to exactly one valid owner (Account/Employee/EnterpriseUser); no portal identity or ambiguous target →SkippedNotEligible(skipped, not a system failure), no multiple inbox rows.- Campaign does not create/modify Account/Employee/login to "make" a target valid; no legacy
Customer_{UserId}is built for new deliveries.
Real-world example: Campaign
Holiday Notice: generation failed after creating the Email, the SMS was not created. → Retry. → Only the SMS is created additionally; Email keeps its provider-progress, parents do not receive a second email.
Campaign detail drawer: per-part diagnostics
- The sent-campaign drawer shows diagnostics independently per part: recipients, email content, attachments — one part failing does not hide the state of other parts.
- Each diagnostic includes status, reason (human-readable), error code (optional), failure timestamp (when known); missing metadata → omit/null, no fabricated values.
- The portal prefers structured diagnostics; it only falls back to a generic error (
Failed to load recipient list) when there is no metadata — allowing backend/frontend to deploy out of sync.
Real-world example: Campaign
Holiday Notice: recipients failed withPermission denied(403, with timestamp), attachments failed with no code, email content loaded. → Admin opens the drawer. → Recipients show reason/code/time; attachments show failed + reason; email content renders normally.
Campaign: manual recipient selection across pages
- Within one search result, manual recipient selection persists across pagination; the checked state reflects the global selection (the union of selected pages).
- Actions only change the current page's selection: Select All / Deselect All in the header applies only to the current page; deselecting one row does not affect other pages.
- One row identity rule for render/match/save of selection; each selected key has exactly one recipient record (no undefined); missing attendee id → fall back to contact/account/employee id.
- On Add:
SelectedRowKeys+SelectedRecipientshold the multi-page union; count = size of that union; reopening the draft keeps off-page selection. - A new search resets selection + back to page 1; changing pagination within the current result does not reset.
Real-world example: A search for parents returns 3 pages; select a few on pages 1–2, then Select All on page 3. → Add. → Only page 3 is added via Select All; selection on pages 1–2 is kept; count = the union of all 3 pages.
Got it? See how they assemble into the system in Architecture.