The personal inbox uses the unified Notification aggregate as its write-side fact. The read side projects the hot table through QueryShape and can merge a read-only archive table. HTTP operations are restricted to the current UserId; only the internal NotificationService accepts an explicit recipient.
1. Aggregate fields and input boundaries
| Field | Persistence limit | Current validation |
|---|---|---|
| Id/NotificationId | aggregate key | repository replaces placeholder "0" |
| UserId | 64 | non-empty only; HTTP writes "0" without a user |
| Code | 100 | non-empty only |
| Title | 255 | non-empty; no length pre-check |
| Body | max text | null becomes empty string |
| Category | 50 | blank becomes General |
| LinkUrl | 512 | no scheme/host validation |
| LinkText | 100 | no length pre-check |
| MetadataJson | max JSON column | not parsed; delivery converts invalid JSON to empty contact data |
Type, Severity, and Status persist SmartEnum names. Unknown stored values fail closed during restoration. New rows default to Business, Info, and Unread.
var result = await notificationService.CreateAsync( tenantId: tenant.Id, userId: assignee.UserId, code: "tickets.assignment.changed", title: "A ticket was assigned to you", body: $"Ticket {ticket.Number} must be answered before its SLA deadline.", type: NotificationType.Todo, category: "Tickets", severity: NotificationSeverity.Warning, linkUrl: $"/tickets/{ticket.Id}", // Include only contact data needed by delivery; never serialize User/Ticket graphs. metadataJson: JsonSerializer.Serialize(new { email = assignee.Email }), cancellationToken);
// There is no DeduplicationKey today; replay creates another notification.if (result.IsFailure) return Result.Failure(result.Error);2. State transitions
MarkRead and Archive are idempotent. MarkRead on Archived succeeds and preserves Archived; if ReadAt was null, the aggregate stores this readAt. MarkUnread clears ReadAt but returns Notification.AlreadyArchived for Archived.
ArchiveNotification only changes Status in the hot row. It does not move data into SysNotification_Archive. The generic retention executor performs age-based physical archival, so these two meanings of “archive” must not be conflated.
3. Current-user ownership
Read, Unread, and Archive load the aggregate by Id, then NotificationService compares both TenantId and UserId. Any mismatch returns NotFound to avoid existence disclosure.
[Fact]public async Task MarkRead_Should_Not_Reveal_Another_Users_Notification(){ // user-a owns the row; the request principal is user-b in the same tenant. var notification = Notification.Create( "tenant-a", "user-a", "invoice.ready", "Invoice ready", "Your service invoice has been generated. View it in billing.").Value!; repository.Seed(notification);
// The service restores the aggregate before checking TenantId + UserId ownership. var result = await service.MarkReadAsync( "tenant-a", "user-b", notification.Id, CancellationToken.None);
// The response is a uniform NotFound and the state remains unchanged. result.Error.Code.ShouldBe(NotificationErrorCodes.NotFound); notification.Status.ShouldBe(NotificationStatus.Unread);}FindByIdAsync itself accepts neither tenant nor user and relies on the generic repository’s ambient tenant filter. HTTP ownership uses CurrentUser.User.TenantId. During impersonation, UserTenant and EffectiveTenant can differ, causing the repository to see a target-tenant row while ownership rejects with the home tenant—or to miss it earlier. Use one EffectiveTenant snapshot throughout.
4. Inbox paging
GET accepts Status, PageIndex, PageSize, and IncludeArchived. The handler maps an invalid page index to 1 and PageSize <= 0 or PageSize > 100 to 20. The store clamps PageIndex to 1..1000 and PageSize to 1..100.
The hot-table query pushes paging down and orders by CreateTime descending, then NotificationId descending. Its predicate always includes TenantId+UserId and optionally Status.
GET /api/notifications?status=Unread&pageIndex=1&pageSize=20&includeArchived=true HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Accept: application/jsonWith IncludeArchived=true, the read store fetches pageIndex * pageSize rows from each table, merges, orders, skips, and takes in memory. Page 1000 at size 100 can read 100,000 rows per side. This is bounded but still deep-page amplification; a production API should use keyset/cursor paging.
5. Unread count and mark-all-read
GetUnreadCount counts UnreadNotificationFilter(tenantId,userId) in the hot repository only. Archived Unread rows do not contribute, so the badge and an IncludeArchived inbox can disagree if retention moves unread data.
MarkAllReadAsync:
- loads every unread aggregate for the tenant/user;
- invokes MarkRead on each row;
- saves the complete collection with SaveRange;
- returns the count.
There is no page or batch cap. A high-backlog user can amplify memory, transaction duration, and update volume. Prefer one scoped set update or bounded batches with a defined ReadAt.
6. Hot and archive tables
The SQL Server NotificationInbox policy has OnlineDays=365, ArchiveDays=1095, Action=ColdStorage, and ArchiveTable=SysNotification_Archive. The executor uses DELETE ... OUTPUT ... INTO in per-tenant transactions and 1,000-row loops.
The generic DataRetention job currently auto-processes only Action=Delete policies. A ColdStorage policy therefore requires the archive-operations path to trigger it; registry presence alone is not proof of scheduled movement.
The archive row is a read-only asymmetric model. Notification commands search the hot repository only. A physically archived item can appear in GET but cannot be changed through the current Read/Unread/Archive commands.
7. Data and rendering safety
Inbox responses expose the complete Body, MetadataJson, and LinkUrl. A UI must:
- treat Body as text or sanitized controlled Markdown;
- allow only relative application links or approved hosts, rejecting javascript/data schemes;
- avoid expanding MetadataJson into DOM, analytics, or error logs;
- apply identical authorization and output encoding to archived rows;
- assign retention and GDPR handling to notification PII.
There is no user-facing soft-delete/clear-inbox command or GDPR contributor. Physical archival retains body and metadata; moving a row out of the hot table is not data minimization.
8. Errors and client behavior
| Condition | Result | Client behavior |
|---|---|---|
| required creation metadata absent | Notification.MetadataRequired | correct input |
| id absent, other user, or tenant mismatch | Notification.NotFound | hide action; do not probe |
| mark Archived as Unread | Notification.AlreadyArchived | preserve archived state |
| repeated Read/Archive | Success | treat as idempotent |
| deep page | clamped/window amplified | avoid deep navigation |
| mutate physically archived row | NotFound | currently read-only |
9. Required tests
- persistence-length boundaries, invalid MetadataJson, and unsafe LinkUrl;
- Unread→Read→Unread, Unread/Read→Archived, and every replay;
- exact MarkRead behavior for Archived with null ReadAt;
- same-tenant other user, cross-tenant, and UserTenant≠EffectiveTenant;
- page boundaries, equal-CreateTime order, and duplicate Id across hot/archive;
- badge/list policy when archive contains Unread;
- MarkAllRead capacity and transaction behavior for 100,000 rows;
- equivalent SqlSugar/EF hot queries and SQL Server archive/restore behavior.