Workflow notification persistence now rides inside the flow command’s transactional fact chain: the platform INotificationChannel writes a per-recipient inbox entry plus a CAP Outbox row through the Notifications module, and any recipient failure fails and rolls back the entire transition. “The approval succeeded but notifications silently vanished” is no longer a legal state; cross-network external delivery (email, DingTalk, etc.) stays asynchronous under the Notifications module’s consumer — delivery confirmation belongs to that domain.
1. Dispatch chain
EventDispatcher invokes task, execution, and workflow listeners in registration order; listener exceptions are logged and rethrown.
2. Events and recipients
| Event | Default recipient |
|---|---|
| TaskCreated | task assignee |
| TaskCompleted | current applicant |
| TaskRejected | current applicant |
| TaskTransferred / Delegated | data.recipientUserIds |
| ParticipantAdded | added participant |
| InstanceCompleted/Cancelled/Terminated/Withdrawn | current applicant |
| CcCreated | CC recipients |
| TaskRemind | pending assignees |
IApplicantResolver can resolve the applicant dynamically and falls back to CurrentApplicantId/StarterId. TaskCreated already creates one task per resolved user, so notifications target concrete assignees.
3. Templates
NotificationDispatcher resolves templates through IWorkflowNotificationTemplateResolver by eventType, businessType, and language; when missing or failing it falls back to built-in Chinese defaults (hard-coded in NotificationDispatcher), e.g. a title shaped like You have a new approval task: {title} in Chinese. Variables undergo controlled brace interpolation.
var engine = new WorkflowEngineBuilder() // Only the direct Builder forwards template and preference ports to the dispatcher. .UsePersistence(store) .UseExpressionEvaluator(new DefaultExpressionEvaluator()) .UseNotificationChannel(channel) .UseNotificationTemplateResolver(templateResolver) .UseNotificationPreferenceStore(preferenceStore) // Build validates required ports only; it provides no persistence or retry for notifications. .Build();4. Preferences
When the builder receives an IWorkflowNotificationPreferenceStore, recipients survive only if InboxEnabled or ExternalDeliveryEnabled is true. Preference lookup failures fail open with a log line.
NotificationService carries its own preferences and suppression rules. The two layers need one policy, otherwise the engine may drop someone the platform layer would have kept, or UI settings may diverge from actual dispatch.
5. Platform adapter
WorkflowNotificationAdapter implements the engine’s INotificationChannel on top of the Notifications module’s INotificationComposer: code workflow.{EventType}, severity mapped Urgent→Error / High→Warning / else Info, type mapping sending approval events to Approval, terminal events to Business, and CC to Todo; metadata JSON carries instanceId, businessKey, eventType, definitionKey, and officeId. Every recipient compose failure or exception increments a counter:
foreach (string userId in notification.RecipientUserIds){ try { var result = await _notificationComposer.ComposeAsync( tenantId: notification.TenantId, userId: userId, code: $"workflow.{notification.EventType}", title: notification.Title, body: notification.Body, type: type, category: notification.BusinessType, severity: severity, linkUrl: null, linkText: null, metadataJson: metadataJson, cancellationToken: cancellationToken);
if (!result.IsSuccess) { failedRecipients++; } } catch (OperationCanceledException) { throw; } catch (Exception exception) when (exception is not OutOfMemoryException) { failedRecipients++; }}
if (failedRecipients > 0){ // Any recipient persistence failure escalates to a channel exception, // letting upper layers fail and roll back the whole transition. throw new InvalidOperationException( $"Workflow notification persistence failed for {failedRecipients} recipient(s).");}Success therefore proves inbox plus Outbox writes; the trade-off is that a broken notification channel blocks approval actions themselves — monitoring the channel with transaction-grade alerting is an operational duty, not optional.
6. Node DSL notifications
NodeListenerDispatcher handles type=notification listeners by reading the __listenerRecipients variable, using channel or node-listener for Title and an expression for Body. It is a separate path from the standard NotificationDispatcher.
An empty or missing recipient list skips sending entirely (no empty notifications); any real send failure now follows the shared failure chain and fails the transition.
7. JobHost wiring
Background reminders are no longer a gap. With SqlSugar present, WorkflowBackgroundJobsExtensions.AddWorkflowBackgroundJobs registers the full chain:
SqlSugarWorkflowPersistenceStoreand the timer tenant execution scope;- the timer-scoped system permission checker
WorkflowTimerSystemPermissionChecker; INotificationChannel → WorkflowNotificationAdapter;- job identity
BackgroundJobIdentities.WorkflowTimer.JobName = "workflow-timer", Quartz[DisallowConcurrentExecution], a five-minute default scan overridden byBackgroundJobs:workflow-timer:IntervalSeconds.
WorkflowTimerProcessor takes ILicenseGate as a required dependency and drives due timers, timeout escalation, and timeout transfer. The execution scope commits business actions, notification Outbox rows, and Fired state atomically under tenant context. A pure in-memory host without SqlSugar registers nothing and the job never enters the descriptor catalog.
8. Failure semantics
| Failure point | Current result |
|---|---|
| Template resolution | log warning, use default template |
| Preference lookup | log warning, keep recipient |
| Single recipient compose | counted as failure, continue others |
| Any failures at end of batch | throw InvalidOperationException; dispatcher rethrows; transition rolls back |
| Listener exceptions | EventDispatcher logs + rethrows |
| CAP external delivery | asynchronous Notifications consumer; Workflow does not wait |
9. External delivery reconciliation boundary
Inbox plus Outbox are already atomic with the transition; cross-network delivery receipts, attempt ledgers, and DLQ reconciliation remain concerns of the Notifications consumer side. Products needing hard SLAs should add them there:
Template versions and final rendered content should also be captured as evidence so retries do not change wording after template edits.
10. Security and privacy
Variables may contain sensitive data and NotificationDispatcher passes them into WorkflowNotification; platform Metadata selects only a few fields today, but titles/bodies can still leak through interpolation. Template allowlists, length caps, HTML/URL-context encoding, recipient tenant checks, and log redaction all need tests.
11. Test checklist
- recipient resolution per event and no-recipient paths;
- applicant resolver fallback;
- missing/failing templates and preference fail-open;
- partial multi-recipient failure rolling back the whole command;
- identical transition replays never duplicating inbox entries;
- JobHost reminder actually sent with atomic Fired commit;
- CAP outage, recovery, duplicate delivery, and reconciliation;
- template injection, oversized variables, and cross-tenant users.