Notifications contains two capability sets that are not yet a closed loop. One is the SysNotification personal inbox and external-channel fan-out. The other is the multi-table UniversalTemplate model with versions, variables, rendering, and channel content adapters. They share a module, but CreateNotification does not invoke template rendering: it accepts Title and Body directly.
1. Boundary the module actually owns
Implemented today:
- a current-user inbox, unread count, and Read/Unread/Archived state;
- user-level global or notification-code preferences;
- the
notification.createdCAP event and severity-based default channels; - delivery ports for SMS, enterprise mail, WeCom, DingTalk, and Feishu;
- tenant templates, version history, variable declarations, and Scriban compilation cache;
- content adapters for Email, IM, SMS, Inbox, and HTML;
- merged SQL Server reads across hot and archived notification tables.
Not implemented today: template-driven notification creation, a stable cross-module intent contract, batch recipients, scheduling, persisted delivery attempts, retries/compensation, deduplication, mandatory-notice rules, push/webhook delivery, attachments, bounce feedback, delivery/open receipts, or a Notifications feature catalog.
2. Current end-to-end shape
NotificationService.CreateAsync resolves preference first. It persists the aggregate only when InboxEnabled=true and always publishes an event afterward. The consumer reads only notificationId and ignores tenantId, userId, code, and skipExternalDelivery, so the payload and consumption semantics are asymmetric.
3. Two HTTP surfaces
Personal notifications
| Method and route | Use case | Authorization action | Current semantics |
|---|---|---|---|
POST /api/notifications | CreateNotification | Create | create for the current UserId; client supplies Title/Body |
GET /api/notifications | GetNotificationInbox | View | status filter, 1..100 page size, archives included by default |
GET /api/notifications/unread-count | GetUnreadCount | View | current tenant/current user |
POST .../{id}/read | MarkNotificationRead | Update | idempotent; Archived stays Archived |
POST .../{id}/unread | MarkNotificationUnread | Update | Archived returns Conflict |
POST .../{id}/archive | ArchiveNotification | Update | changes status; does not move the physical row |
POST .../read-all | MarkAllNotificationsRead | Update | loads all unread rows and SaveRange |
GET/PUT .../preferences | Get/UpdatePreference | View/Update | exact Code or global empty Code |
The permission catalog declares only notifications.notification.view and .update, while CreateNotification requests AuthorizationAction.Create. No .create definition exists, which is governance drift that must be fixed and protected by a real authorization contract test.
Templates
Templates expose 11 generated endpoints: Create, Update, Delete, Get, Search, Versions, Activate, Render, Preview, and Validate. Activate uses the generic Update action, while Preview/Validate/Render use View. The separately declared .activate and .preview permission codes are not referenced by those requests.
4. Core storage models
Notification is a unified TenantAggregateRoot. Templates are a registered multi-table asymmetric exception; an explicit projector translates root, version, and variable records. Child-table operations carry TenantId+TemplateId. Root lookup by TemplateId relies on the global tenant filter instead of an explicit tenant argument.
The default SQL Server retention policy transactionally moves notifications older than 365 days into SysNotification_Archive; ArchiveDays is 1,095 and Action is ColdStorage. Inbox reads default IncludeArchived=true and merge windows from both tables. This is unrelated to the immediate ArchiveNotification status command.
5. Creating a notification today
POST /api/notifications HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "code": "tickets.assignment.changed", "title": "A ticket was assigned to you", "body": "Ticket T-20260715-0042 was reassigned by first-line support.", "type": "Todo", "category": "Tickets", "severity": "Warning", "linkUrl": "/tickets/T-20260715-0042", "metadataJson": "{\"email\":\"agent@example.com\",\"phone\":\"13800000000\"}"}The HTTP handler derives TenantId and UserId from CurrentUser; a client cannot choose another recipient. It writes "0" when UserId is absent. The aggregate validates only that TenantId, UserId, Code, and Title are non-empty. It does not pre-validate persistence lengths, MetadataJson syntax, or LinkUrl schemes.
Internal modules can call NotificationService with a target userId, but must align the ambient EffectiveTenant with the tenant argument and must define template selection, body generation, idempotency, and sensitive-data minimization themselves.
6. Most important current gaps
- CreateNotification is disconnected from templates and never resolves by Code or language.
- Notification Create is absent from the catalog; Template Activate/Preview permissions exist but are unused.
- HTTP handlers use User.TenantId and a UserId=
0fallback instead of one EffectiveTenant/user requirement. - Creation has no business idempotency key; duplicate requests or events create duplicate rows.
- InboxEnabled=false prevents persistence and therefore breaks external consumption.
- The CAP consumer ignores tenantId and skipExternalDelivery and establishes no explicit tenant context.
- Consumer/channel failures are swallowed with no queryable state, retry, or reconciliation.
- EnabledChannels is stored but ignored, and no mandatory-notice rule prevents disabling everything.
- Rendering uses an empty TenantId, does not authorize CallerModule, and treats every engine as Scriban.
- Bilingual Identity seeds share Tenant+TemplateKey, so the second locale is skipped; Identity still uses inline fallback bodies.
- Update ignores Description; import ignores historical versions and variables from its own export JSON.
- HTML, Email, and Inbox body handling is not a production-grade allowlist-sanitization boundary.
7. Reading path
- Inbox, state machine, and archived reads
- Preferences, CAP, and multi-channel delivery
- Templates, versions, variables, and multi-table persistence
- Rendering, channel adaptation, and content safety
- Testing, operations, and GA gates
Related chapters: Authorization, Multitenancy, Auditing, and Operations.
8. Source checks
# Every generated notification/template route and authorization action.rg -n 'GenerateEndpoint|AuthorizationAction' \ src/Platform/Notifications -g '*.cs'
# The real create, publish, consume, and exception path.rg -n 'notification.created|skipExternalDelivery|FindByIdAsync|catch \(Exception' \ src/Platform/Notifications -g '*.cs'
# Template tenant, engine, module-caller, and version-number boundaries.rg -n 'GetByKeyAsync\(|CallerModule|TemplateEngine|OrderByDescending' \ src/Platform/Notifications -g '*.cs'