Communication — System architecture
Clear boundary: logic (Platform/automation) decides what to send; dispatch ensures records exist; the send pipeline handles actual delivery. Automation handlers never call providers directly.
Overview
In one sentence: events and schedules from the left flow into
NotificationScheduler; the worker pipeline (Scheduler → Generator → Distributor) renders and sends; webhook updates state back toOutgoingMessage.
Choose send path by producer
| Producer | Path |
|---|---|
| Manual / immediate entity message | NotificationJob → NotificationScheduler |
| Direct reminder | NotificationJob → NotificationScheduler → OutgoingMessage |
| UI-only reminder | NotificationJob → NotificationScheduler → UserMessage |
| Report subscription | AutomationScheduler → MessagingJob+Blob and/or NotificationJob → NotificationScheduler → OutgoingMessage |
| Messaging campaign | MessagingJob+Blob → NotificationScheduler → OutgoingMessage |
| Automation skipped/deactivated | No send; result is in AutomationScheduler.DispatchResultJson + support screens |
Ensure contracts (IDeliveryDispatcher)
Every artifact is created through an Ensure* contract:
IDeliveryDispatcher
EnsureAutomationJobAsync EnsureNotificationJobAsync
EnsureMessagingJobAsync EnsureUserMessageAsync
EnsureOutgoingMessageAsync EnsureNotificationSchedulerAsyncEach method: find by deterministic key → check compatibility (InputHash/ContentHash + immutable fields) → create if missing → return stable coordinates. Compatible → idempotent success; immutable mismatch → conflict (stop sending, report support).
Key policy (short version)
Keys decide "where history opens" and "how duplicates are prevented".
NotificationJob
Manual / transactional: PartitionKey = PrimaryEntity.Guid
RowKey = {CreatedReverseTicks}_{NotificationJobGuid}
Direct automation reminder: PartitionKey = PrimaryEntity.Guid
RowKey = AutomationRowKey
Report subscription: PartitionKey = ReportSubscription.Guid
RowKey = AutomationRowKey
Messaging campaign: PartitionKey = {BusinessUnit.Guid}_Campaign
RowKey = Campaign.GuidPartitionKey is the entity where the user expects to open history — not the internal id of automation. RowKey = AutomationRowKey is the idempotency bridge from automation to notification. Event-derived notifications use NotificationEventKey (see Platform).
UserMessage
Customer: PartitionKey = user:{User.Id}
Staff: PartitionKey = employee:{Employee.Guid}
RowKey = {SourceReverseTicks}_{NotificationJob.RowKey}Prefixes user: / employee: keep identity domains separate. SourceReverseTicks must come from a stable source timestamp (not write-time) so Generator retry does not create duplicate rows.
OutgoingMessage + send claim
Notification-generated: PartitionKey = NotificationJob.RowKey
Report/campaign: PartitionKey = MessagingJob.Guid
RowKey = {RecipientKeyHash}_{DeliveryMethod} (+ _{AttemptNo} only when intentionally a new attempt)RecipientKeyHash is derived from stable recipient identity + normalized destination, not display text. One row / recipient / channel; Generator retry reuses the row.
Claim before provider call:
Created/RetryReady -> Sending WHERE PartitionKey, RowKey, Status in (Created, RetryReady)Rows already Sending | SentToProvider | Delivered | Opened | FailedFinal → duplicate queue message exits without calling provider. Provider success → SentToProvider + provider message id; webhook advances state. Only audited support-retry may reset to RetryReady.
NotificationScheduler dispatch guard
UNIQUE(ArtifactTypeId, ArtifactPartitionKey, ArtifactRowKey, NotificationEventTypeId)EnsureNotificationSchedulerAsync returns the existing row when the guard matches. NotificationEventTypeId is the domain notification discriminator — mapped explicitly at the notification boundary, not assumed equal to the EntityEvent EventTypeId.
Partial failure handling
| Found during retry | Behavior |
|---|---|
NotificationJob exists, scheduler missing | Recreate scheduler through dispatch guard |
UserMessage missing recipient row | Insert missing row with deterministic key |
OutgoingMessage already exists | Reuse; provider call is decided by send claim |
MessagingJob exists, blob incompatible | Stop, report conflict |
| Any key hash mismatch | Stop, report conflict |
Next: concrete files & workers in Codebase flow.