26 TDD tasks across six phases — data layer, message contracts, the new ScadaLink.NotificationOutbox project (actor, dispatcher, Email adapter), site S&F retarget and central wiring, Central UI, and verification.
42 KiB
Notification Outbox — Code Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Build the central Notification Outbox feature in the ScadaLink src/ codebase — sites store-and-forward notifications to the central cluster, which logs each to a Notifications table and delivers it via per-type adapters with retry, parking, status handles, and KPIs.
Architecture: A new ScadaLink.NotificationOutbox project hosts a NotificationOutboxActor cluster singleton on the active central node. Sites enqueue notifications into the existing site Store-and-Forward Engine (notification category, retargeted from SMTP to "central"); the S&F engine forwards them to central via ClusterClient; the CentralCommunicationActor routes each NotificationSubmit to the outbox singleton, which inserts a row into the central MS SQL Notifications table (insert-if-not-exists on a site-generated NotificationId GUID) and acks. A timer-driven dispatcher polls due rows and delivers them through an INotificationDeliveryAdapter (Email adapter now; Teams later). A Blazor page surfaces KPIs and a queryable list with Retry/Discard.
Tech Stack: .NET 10, Akka.NET (cluster singletons, ClusterClient, TestKit), EF Core (MS SQL; SQLite in-memory for tests), Blazor Server + Bootstrap, xUnit + NSubstitute + bUnit. Solution: ScadaLink.slnx.
Authoritative design: docs/plans/notif.md and docs/requirements/Component-NotificationOutbox.md. Read both before starting.
Conventions (read once, applies to every task)
These were confirmed by exploring the existing codebase. Follow them in every task.
- Entities (Commons): POCOs in
src/ScadaLink.Commons/Entities/<Area>/. Auto-properties, parameterized constructor with null checks, navigation collections initialised tonew List<T>(). No data annotations. - EF mapping (ConfigurationDatabase): Fluent
IEntityTypeConfiguration<T>classes insrc/ScadaLink.ConfigurationDatabase/Configurations/, auto-applied byApplyConfigurationsFromAssembly. Enums stored as strings via.HasConversion<string>(). Add aDbSet<T>toScadaLinkDbContext. - Repositories: Interface in
src/ScadaLink.Commons/Interfaces/Repositories/, implementation insrc/ScadaLink.ConfigurationDatabase/Repositories/. InjectScadaLinkDbContext, use_context.Set<T>(), expose explicitSaveChangesAsync. Register inConfigurationDatabase/ServiceCollectionExtensions.cswithAddScoped. - Migrations:
dotnet ef migrations add <Name> --project src/ScadaLink.ConfigurationDatabase— timestamp-named. Applied viaMigrationHelper.ApplyOrValidateMigrationsAsync(auto in dev). - Message contracts (Commons):
recordtypes insrc/ScadaLink.Commons/Messages/<Area>/, named positional params, additive-only evolution. - Options pattern:
<Component>Optionsclass owned by the component project; component'sServiceCollectionExtensions.Add<Component>()callsservices.AddOptions<T>().BindConfiguration("ScadaLink:<Section>"); Host alsoservices.Configure<T>(...). Config lives inappsettings.Central.json/appsettings.Site.json. - Actors: No Akka.DI framework. Dependencies passed via
Props.Create(() => new XActor(...)). Actors that need scoped services takeIServiceProviderand callCreateScope(). Cluster singletons useClusterSingletonManager.Props+ClusterSingletonProxy.Props, created insrc/ScadaLink.Host/Actors/AkkaHostedService.cs. - Tests: xUnit, NSubstitute, built-in
Assert. Onetests/ScadaLink.<Component>.Tests/project persrc/project. Actor tests inheritAkka.TestKit.Xunit2.TestKit. Repository tests use SQLite in-memory (DataSource=:memory:,OpenConnection()+EnsureCreated(),IDisposable). Blazor tests inherit bUnitBunitContext. Test naming:Method_Scenario_Result. - Run tests: whole suite
dotnet test ScadaLink.slnx; single projectdotnet test tests/ScadaLink.<X>.Tests/ScadaLink.<X>.Tests.csproj; single test--filter "FullyQualifiedName~<Class>.<Method>". - Build:
dotnet build ScadaLink.slnx. - TDD: every task writes the failing test first, runs it red, implements, runs it green, commits. Use the superpowers-extended-cc:test-driven-development discipline.
- Commits: one per task, message
feat(notification-outbox): <task summary>.
Status lifecycle (central Notifications table — Forwarding is site-local, never stored centrally):
Pending → Retrying → Delivered | Parked, plus Discarded (operator action only).
Phase A — Data layer (Commons + ConfigurationDatabase)
Task 1: Notification enums
Files:
- Create:
src/ScadaLink.Commons/Types/Enums/NotificationType.cs - Create:
src/ScadaLink.Commons/Types/Enums/NotificationStatus.cs - Test:
tests/ScadaLink.Commons.Tests/Types/NotificationEnumTests.cs(create if the test project lacks aTypes/folder)
Step 1 — failing test. Assert the enums expose exactly the expected members:
[Fact]
public void NotificationStatus_HasExactlyTheCentralStates()
{
var names = Enum.GetNames<NotificationStatus>();
Assert.Equal(
new[] { "Pending", "Retrying", "Delivered", "Parked", "Discarded" },
names);
}
[Fact]
public void NotificationType_HasEmail()
{
Assert.True(Enum.IsDefined(NotificationType.Email));
}
Note: Forwarding is intentionally NOT a NotificationStatus member — it is a site-local concept (Task 19), never persisted centrally.
Step 2 — run red: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter "FullyQualifiedName~NotificationEnumTests" → FAIL (types don't exist).
Step 3 — implement.
// NotificationType.cs — namespace ScadaLink.Commons.Types.Enums
public enum NotificationType { Email } // Teams and others added later
// NotificationStatus.cs — namespace ScadaLink.Commons.Types.Enums
public enum NotificationStatus { Pending, Retrying, Delivered, Parked, Discarded }
Step 4 — run green. Same filter → PASS.
Step 5 — commit:
git add src/ScadaLink.Commons/Types/Enums/NotificationType.cs src/ScadaLink.Commons/Types/Enums/NotificationStatus.cs tests/ScadaLink.Commons.Tests/Types/NotificationEnumTests.cs
git commit -m "feat(notification-outbox): add NotificationType and NotificationStatus enums"
Task 2: Notification entity POCO
Files:
- Create:
src/ScadaLink.Commons/Entities/Notifications/Notification.cs - Test:
tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs
Step 1 — failing test. Verify the constructor sets required fields, defaults Status to Pending and RetryCount to 0, and rejects nulls:
[Fact]
public void Constructor_SetsDefaults()
{
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
Assert.Equal(NotificationStatus.Pending, n.Status);
Assert.Equal(0, n.RetryCount);
Assert.Equal("id-1", n.NotificationId);
}
[Fact]
public void Constructor_NullListName_Throws()
=> Assert.Throws<ArgumentNullException>(
() => new Notification("id", NotificationType.Email, null!, "s", "b", "SiteA"));
Step 2 — run red.
Step 3 — implement. Match the Notifications table schema in notif.md:
namespace ScadaLink.Commons.Entities.Notifications;
public class Notification
{
public string NotificationId { get; set; } // GUID PK, generated at site
public NotificationType Type { get; set; }
public string ListName { get; set; }
public string Subject { get; set; }
public string Body { get; set; }
public string? TypeData { get; set; } // JSON extensibility hook
public NotificationStatus Status { get; set; } = NotificationStatus.Pending;
public int RetryCount { get; set; }
public string? LastError { get; set; }
public string? ResolvedTargets { get; set; } // snapshotted at delivery, for audit
public string SourceSiteId { get; set; }
public string? SourceInstanceId { get; set; }
public string? SourceScript { get; set; }
public DateTimeOffset SiteEnqueuedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; } // central ingest time
public DateTimeOffset? LastAttemptAt { get; set; }
public DateTimeOffset? NextAttemptAt { get; set; }
public DateTimeOffset? DeliveredAt { get; set; }
public Notification(string notificationId, NotificationType type, string listName,
string subject, string body, string sourceSiteId)
{
NotificationId = notificationId ?? throw new ArgumentNullException(nameof(notificationId));
Type = type;
ListName = listName ?? throw new ArgumentNullException(nameof(listName));
Subject = subject ?? throw new ArgumentNullException(nameof(subject));
Body = body ?? throw new ArgumentNullException(nameof(body));
SourceSiteId = sourceSiteId ?? throw new ArgumentNullException(nameof(sourceSiteId));
}
}
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add Notification entity).
Task 3: Type field on NotificationList
Files:
- Modify:
src/ScadaLink.Commons/Entities/Notifications/NotificationList.cs - Modify:
src/ScadaLink.ConfigurationDatabase/Configurations/NotificationConfiguration.cs(NotificationListConfiguration) - Test:
tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs(add a test to the notification repository tests)
Step 1 — failing test. A NotificationList round-trips its Type through the repository:
[Fact]
public async Task NotificationList_PersistsType()
{
var list = new NotificationList("ops") { Type = NotificationType.Email };
await _notificationRepo.AddNotificationListAsync(list);
await _notificationRepo.SaveChangesAsync();
_context.ChangeTracker.Clear();
var loaded = await _notificationRepo.GetListByNameAsync("ops");
Assert.Equal(NotificationType.Email, loaded!.Type);
}
Step 2 — run red.
Step 3 — implement. Add to NotificationList: public NotificationType Type { get; set; } = NotificationType.Email;. In NotificationListConfiguration.Configure, add builder.Property(n => n.Type).HasConversion<string>().HasMaxLength(32).IsRequired();.
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add Type field to NotificationList).
Task 4: Notification EF configuration + DbSet
Files:
- Create:
src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs - Modify:
src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs(addDbSet<Notification>) - Test:
tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs
Step 1 — failing test. A Notification round-trips all fields through the DbContext (use the SQLite in-memory fixture pattern). Assert the Status/Type enums persist as strings and the row is found by NotificationId.
Step 2 — run red.
Step 3 — implement. Configuration:
public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notification>
{
public void Configure(EntityTypeBuilder<Notification> builder)
{
builder.ToTable("Notifications");
builder.HasKey(n => n.NotificationId);
builder.Property(n => n.NotificationId).HasMaxLength(64);
builder.Property(n => n.Type).HasConversion<string>().HasMaxLength(32).IsRequired();
builder.Property(n => n.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
builder.Property(n => n.ListName).HasMaxLength(200).IsRequired();
builder.Property(n => n.Subject).HasMaxLength(1000).IsRequired();
builder.Property(n => n.Body).IsRequired(); // nvarchar(max)
builder.Property(n => n.TypeData); // nvarchar(max), nullable
builder.Property(n => n.ResolvedTargets); // nvarchar(max), nullable
builder.Property(n => n.LastError).HasMaxLength(4000);
builder.Property(n => n.SourceSiteId).HasMaxLength(100).IsRequired();
builder.Property(n => n.SourceInstanceId).HasMaxLength(200);
builder.Property(n => n.SourceScript).HasMaxLength(200);
builder.HasIndex(n => new { n.Status, n.NextAttemptAt }); // dispatcher polling
builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt }); // KPIs / UI query
}
}
Add public DbSet<Notification> Notifications => Set<Notification>(); to ScadaLinkDbContext.
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add Notification EF configuration and DbSet).
Task 5: INotificationOutboxRepository + implementation
Files:
- Create:
src/ScadaLink.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs - Create:
src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs - Modify:
src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs(registerAddScoped) - Test:
tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs
Step 1 — failing tests. Cover the operations the outbox actor needs:
InsertIfNotExistsAsyncinserts a new row and returnstrue; a second call with the sameNotificationIdreturnsfalseand does not duplicate (idempotency key).GetDueAsync(now, batchSize)returnsPendingrows andRetryingrows withNextAttemptAt <= now, ordered byCreatedAt, capped atbatchSize.UpdateAsyncpersists status transitions.GetByIdAsyncreturns a row or null.QueryAsync(filter, page, pageSize)filters by status/type/source site and paginates.DeleteTerminalOlderThanAsync(cutoff)bulk-deletesDelivered/Parked/Discardedrows older thancutoffand returns the count; leaves non-terminal rows.ComputeKpisAsyncreturns queue depth, stuck count, parked count, delivered-last-window, oldest-pending age.
Step 2 — run red.
Step 3 — implement. Interface:
public interface INotificationOutboxRepository
{
Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken ct = default);
Task<IReadOnlyList<Notification>> GetDueAsync(DateTimeOffset now, int batchSize, CancellationToken ct = default);
Task<Notification?> GetByIdAsync(string notificationId, CancellationToken ct = default);
Task UpdateAsync(Notification n, CancellationToken ct = default);
Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken ct = default);
Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct = default);
Task<NotificationKpiSnapshot> ComputeKpisAsync(DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken ct = default);
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
NotificationOutboxFilter (a record in Commons Types/) and NotificationKpiSnapshot (a record) are created in this task alongside the interface. InsertIfNotExistsAsync: check await _context.Notifications.FindAsync(...), if present return false, else AddAsync + SaveChangesAsync, return true. DeleteTerminalOlderThanAsync: use ExecuteDeleteAsync with a Where on terminal statuses and CreatedAt < cutoff. Register in ServiceCollectionExtensions.AddConfigurationDatabase.
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add NotificationOutbox repository).
Task 6: EF migration
Files:
- Create:
src/ScadaLink.ConfigurationDatabase/Migrations/<timestamp>_AddNotificationsTable.cs(generated)
Step 1 — generate:
dotnet ef migrations add AddNotificationsTable --project src/ScadaLink.ConfigurationDatabase
This also picks up the NotificationList.Type column from Task 3.
Step 2 — verify. Inspect the generated migration: confirm a Notifications table with the columns and two indexes from Task 4, and an AlterColumn/AddColumn for NotificationLists.Type. Run the ConfigurationDatabase test project — the SQLite EnsureCreated() fixture builds from the model, and dotnet build ScadaLink.slnx must succeed.
Step 3 — run: dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj → PASS.
Step 4 — commit (feat(notification-outbox): add Notifications table migration).
Phase B — Message contracts (Commons)
Task 7: Site↔central notification message contracts
Files:
- Create:
src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs - Test:
tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs
Step 1 — failing test. A trivial construction/round-trip test (these are records — assert positional construction and value equality; if the project has a serialization test helper, round-trip through it).
Step 2 — run red.
Step 3 — implement. Namespace ScadaLink.Commons.Messages.Notification:
// Site → Central: submit a notification for central delivery (fire-and-forget with ack).
public record NotificationSubmit(
string NotificationId, string ListName, string Subject, string Body,
string SourceSiteId, string? SourceInstanceId, string? SourceScript,
DateTimeOffset SiteEnqueuedAt);
// Central → Site: ack after the row is persisted (idempotent — safe to re-send).
public record NotificationSubmitAck(string NotificationId, bool Accepted, string? Error);
// Site → Central: query delivery status for a NotificationId.
public record NotificationStatusQuery(string CorrelationId, string NotificationId);
public record NotificationStatusResponse(
string CorrelationId, bool Found, string Status,
int RetryCount, string? LastError,
DateTimeOffset? DeliveredAt);
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add site/central notification message contracts).
Task 8: Outbox UI query/action contracts
Files:
- Create:
src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs - Test:
tests/ScadaLink.Commons.Tests/Messages/NotificationOutboxQueriesTests.cs
Step 1 — failing test. Construction test as in Task 7.
Step 2 — run red.
Step 3 — implement. Records the Central UI / CommunicationService use to talk to the outbox actor:
public record NotificationOutboxQueryRequest(
string CorrelationId, string? StatusFilter, string? TypeFilter, string? SourceSiteFilter,
string? ListNameFilter, bool StuckOnly, string? SubjectKeyword,
DateTimeOffset? From, DateTimeOffset? To, int PageNumber, int PageSize);
public record NotificationSummary(
string NotificationId, string Type, string ListName, string Subject, string Status,
int RetryCount, string? LastError, string SourceSiteId, string? SourceInstanceId,
DateTimeOffset CreatedAt, DateTimeOffset? DeliveredAt, bool IsStuck);
public record NotificationOutboxQueryResponse(
string CorrelationId, bool Success, string? ErrorMessage,
IReadOnlyList<NotificationSummary> Notifications, int TotalCount);
public record RetryNotificationRequest(string CorrelationId, string NotificationId);
public record RetryNotificationResponse(string CorrelationId, bool Success, string? ErrorMessage);
public record DiscardNotificationRequest(string CorrelationId, string NotificationId);
public record DiscardNotificationResponse(string CorrelationId, bool Success, string? ErrorMessage);
public record NotificationKpiRequest(string CorrelationId);
public record NotificationKpiResponse(
string CorrelationId, int QueueDepth, int StuckCount, int ParkedCount,
int DeliveredLastInterval, TimeSpan? OldestPendingAge);
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add outbox query and action contracts).
Phase C — NotificationOutbox project + delivery
Task 9: Create the ScadaLink.NotificationOutbox project
Files:
- Create:
src/ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj - Create:
tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj - Modify:
ScadaLink.slnx(add both projects)
Step 1 — create the projects. Copy the .csproj shape from src/ScadaLink.NotificationService/ScadaLink.NotificationService.csproj (same TargetFramework, central-managed package versions via Directory.Packages.props). The src project references ScadaLink.Commons and Akka packages (Akka, Akka.Cluster.Tools). The test project mirrors tests/ScadaLink.NotificationService.Tests/ (xUnit, NSubstitute, Akka.TestKit.Xunit2) and references the new src project. Add both <Project> entries to ScadaLink.slnx.
Step 2 — add a placeholder test so the test project is non-empty:
public class ProjectSmokeTest { [Fact] public void ProjectCompiles() => Assert.True(true); }
Step 3 — verify: dotnet build ScadaLink.slnx succeeds; dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj → PASS.
Step 4 — commit (feat(notification-outbox): scaffold ScadaLink.NotificationOutbox project).
Task 10: NotificationOutboxOptions
Files:
- Create:
src/ScadaLink.NotificationOutbox/NotificationOutboxOptions.cs - Test:
tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxOptionsTests.cs
Step 1 — failing test. Assert the defaults.
Step 2 — run red.
Step 3 — implement.
public class NotificationOutboxOptions
{
public TimeSpan DispatchInterval { get; set; } = TimeSpan.FromSeconds(10);
public int DispatchBatchSize { get; set; } = 100;
public TimeSpan StuckAgeThreshold { get; set; } = TimeSpan.FromMinutes(10);
public TimeSpan TerminalRetention { get; set; } = TimeSpan.FromDays(365);
public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromDays(1);
public TimeSpan DeliveredKpiWindow { get; set; } = TimeSpan.FromMinutes(1);
}
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add NotificationOutboxOptions).
Task 11: INotificationDeliveryAdapter + DeliveryOutcome
Files:
- Create:
src/ScadaLink.NotificationOutbox/Delivery/INotificationDeliveryAdapter.cs - Create:
src/ScadaLink.NotificationOutbox/Delivery/DeliveryOutcome.cs - Test:
tests/ScadaLink.NotificationOutbox.Tests/Delivery/DeliveryOutcomeTests.cs
Step 1 — failing test. Assert DeliveryOutcome factory methods produce the right classification.
Step 2 — run red.
Step 3 — implement. Mirror the External System Gateway error-classification pattern:
public enum DeliveryResult { Success, TransientFailure, PermanentFailure }
public record DeliveryOutcome(DeliveryResult Result, string? ResolvedTargets, string? Error)
{
public static DeliveryOutcome Success(string resolvedTargets) => new(DeliveryResult.Success, resolvedTargets, null);
public static DeliveryOutcome Transient(string error) => new(DeliveryResult.TransientFailure, null, error);
public static DeliveryOutcome Permanent(string error) => new(DeliveryResult.PermanentFailure, null, error);
}
public interface INotificationDeliveryAdapter
{
NotificationType Type { get; }
Task<DeliveryOutcome> DeliverAsync(Notification notification, CancellationToken ct = default);
}
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add delivery adapter abstraction).
Task 12: EmailNotificationDeliveryAdapter
Files:
- Create:
src/ScadaLink.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs - Modify:
src/ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj(referenceScadaLink.NotificationServiceforISmtpClientWrapper) - Test:
tests/ScadaLink.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs
Step 1 — failing tests. Using NSubstitute mocks of INotificationOutboxRepository-resolved data and a substituted ISmtpClientWrapper:
- list resolved + send succeeds →
DeliveryResult.Success,ResolvedTargetslists the recipient addresses. - list not found / no recipients →
PermanentFailure. - SMTP throws
SmtpPermanentException→PermanentFailure. - SMTP throws a transient error (socket/timeout) →
TransientFailure.
Step 2 — run red.
Step 3 — implement. The adapter resolves the list + recipients + SMTP config from INotificationRepository (the existing notification-list repo — recipients are resolved centrally at delivery time), composes and sends via the existing ISmtpClientWrapper (Func<ISmtpClientWrapper> injected, same as NotificationService), classifies errors identically to NotificationDeliveryService. Reuse the SMTP composition logic from src/ScadaLink.NotificationService/NotificationDeliveryService.cs (BCC delivery, plain text, address validation, the SmtpPermanentException → permanent mapping). On success return DeliveryOutcome.Success(<comma-joined recipient addresses>). Type => NotificationType.Email.
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add Email delivery adapter).
Task 13: NotificationOutboxActor — ingest
Files:
- Create:
src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs - Create:
src/ScadaLink.NotificationOutbox/Messages/InternalMessages.cs(actor-internal tick messages) - Test:
tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs
Step 1 — failing tests (TestKit). The actor takes IServiceProvider, NotificationOutboxOptions, ILogger. Use a mocked INotificationOutboxRepository registered in the test ServiceProvider:
- Send
NotificationSubmit→ actor callsInsertIfNotExistsAsyncwith aNotificationwhose fields map from the message,Status = Pending,CreatedAtset; repliesNotificationSubmitAck(NotificationId, Accepted: true, null)toSender. - Send the same
NotificationSubmittwice → secondInsertIfNotExistsAsyncreturns false; actor still repliesAccepted: true(idempotent — the row already exists, ack so the site clears its buffer). - Repository throws → actor replies
Accepted: falsewith the error (site will retry the forward).
Step 2 — run red.
Step 3 — implement. ReceiveActor. On NotificationSubmit: build a Notification, CreateScope() to resolve INotificationOutboxRepository, call InsertIfNotExistsAsync, PipeTo the result back so the reply preserves Sender. Reply NotificationSubmitAck. Keep dispatch (Task 14) out of this task — ingest only.
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add NotificationOutboxActor ingest).
Task 14: NotificationOutboxActor — dispatcher loop
Files:
- Modify:
src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs - Test:
tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs
Step 1 — failing tests (TestKit, with a registered set of INotificationDeliveryAdapter and a mocked repo):
- On a
DispatchTick, the actor callsGetDueAsync, and for each row invokes the adapter for itsType. - adapter
Success→ row updated toDelivered,DeliveredAt/ResolvedTargets/LastAttemptAtset,UpdateAsynccalled. - adapter
TransientFailure→Retrying,RetryCountincremented,NextAttemptAt = now + retry interval,LastErrorset. - adapter
TransientFailurewhenRetryCountalready at the SMTP-config max →Parked. - adapter
PermanentFailure→Parkedimmediately,LastErrorset. - no adapter for the row's
Type→Parkedwith an explanatory error.
Step 2 — run red.
Step 3 — implement. IWithTimers; in PreStart start a periodic DispatchTick every options.DispatchInterval. On DispatchTick: scope-resolve the repo, GetDueAsync(now, options.DispatchBatchSize), and for each notification resolve the adapter from a Dictionary<NotificationType, INotificationDeliveryAdapter> (injected), await DeliverAsync, apply the status transition, UpdateAsync. Retry count/interval come from the central SMTP config (SmtpConfiguration.MaxRetries / RetryDelay via INotificationRepository). Run delivery on a blocking-safe path (the actor PipeTos the async work; do not block the actor thread). Guard against overlapping ticks (ignore a new tick while one is in flight).
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add dispatcher loop to NotificationOutboxActor).
Task 15: NotificationOutboxActor — query, retry, discard, KPIs
Files:
- Modify:
src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs - Test:
tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs
Step 1 — failing tests (TestKit):
NotificationOutboxQueryRequest→ actor callsQueryAsync, repliesNotificationOutboxQueryResponsewith mappedNotificationSummaryrows;IsStucktrue whenStatusisPending/RetryingandCreatedAtolder thanoptions.StuckAgeThreshold.NotificationStatusQuery→ repliesNotificationStatusResponse(Found:falsewhen the id is unknown).RetryNotificationRequeston aParkedrow → row reset toPending,RetryCount0,NextAttemptAtcleared; replies success. On a non-Parkedrow →Success:false.DiscardNotificationRequeston aParkedrow →Status = Discarded; replies success.NotificationKpiRequest→ repliesNotificationKpiResponsefromComputeKpisAsync(stuck cutoff = now −StuckAgeThreshold; delivered window = now −DeliveredKpiWindow).
Step 2 — run red.
Step 3 — implement the additional Receive<> handlers, each scope-resolving the repo and PipeTo-ing the reply.
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add query, retry, discard, and KPI handlers).
Task 16: Daily purge job
Files:
- Modify:
src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs - Test:
tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs
Step 1 — failing test. On a PurgeTick, the actor calls DeleteTerminalOlderThanAsync(now − options.TerminalRetention).
Step 2 — run red.
Step 3 — implement. In PreStart start a second periodic timer PurgeTick every options.PurgeInterval. Handler scope-resolves the repo and calls DeleteTerminalOlderThanAsync; log the deleted count.
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add daily terminal-row purge).
Task 17: AddNotificationOutbox DI extension
Files:
- Create:
src/ScadaLink.NotificationOutbox/ServiceCollectionExtensions.cs - Test:
tests/ScadaLink.NotificationOutbox.Tests/ServiceRegistrationTests.cs
Step 1 — failing test. Build a ServiceCollection, call AddNotificationOutbox, and assert NotificationOutboxOptions, the EmailNotificationDeliveryAdapter, and the adapter dictionary resolve.
Step 2 — run red.
Step 3 — implement. public const string OptionsSection = "ScadaLink:NotificationOutbox"; plus AddNotificationOutbox(this IServiceCollection) registering AddOptions<NotificationOutboxOptions>().BindConfiguration(OptionsSection), the SMTP client Func<ISmtpClientWrapper> (reuse NotificationService's registration or register here), EmailNotificationDeliveryAdapter, and a registration that exposes IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> built from all registered adapters.
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add AddNotificationOutbox DI registration).
Phase D — Site retarget + central wiring
Task 18: Retarget the site S&F notification handler to forward to central
Files:
- Modify:
src/ScadaLink.StoreAndForward/StoreAndForwardService.csand/or the site registration that wires theNotificationcategory delivery handler - Modify:
src/ScadaLink.Host/SiteServiceRegistration.cs(where the notification handler is registered) - Test:
tests/ScadaLink.StoreAndForward.Tests/(a test that the registered notification handler forwards to the communication actor and treats an ack as success)
Step 1 — investigate + failing test. Currently the Notification category handler calls NotificationDeliveryService.DeliverBufferedAsync. The new handler must instead send a NotificationSubmit to central via the site's communication actor (ClusterClient.Send("/user/central-communication", submit)) and treat a NotificationSubmitAck(Accepted:true) as delivered (true), a non-ack/timeout as transient (throw), so S&F retries the forward. Write a test with a TestProbe standing in for the central client: handler invoked → probe receives NotificationSubmit; reply NotificationSubmitAck(Accepted:true) → handler result true; timeout → handler throws (transient).
Step 2 — run red.
Step 3 — implement. Add a NotificationForwarder (small class or the handler lambda) that holds the site communication actor ref and does Ask<NotificationSubmitAck> with the host-configured forward-retry timeout. Register it as the StoreAndForwardCategory.Notification delivery handler in SiteServiceRegistration, replacing the NotificationDeliveryService handler. The S&F engine already buffers/retries on a thrown (transient) result — no S&F core change needed.
Step 4 — run green. Step 5 — commit (feat(notification-outbox): forward site S&F notifications to central).
Task 19: Notify.Send async + Notify.Status (SiteRuntime)
Files:
- Modify:
src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs(NotifyHelper,NotifyTarget) - Test:
tests/ScadaLink.SiteRuntime.Tests/(Notify API tests)
Step 1 — failing tests.
Notify.To("list").Send("subj","body")generates a GUIDNotificationId, enqueues aStoreAndForwardCategory.Notificationmessage intoStoreAndForwardService(target"central", payload = serializedNotificationSubmit), and returns theNotificationIdstring immediately.Notify.Status(id)issues aNotificationStatusQueryto central and returns the mapped status record; while the notification is still in the site S&F buffer (central has no row / query saysFound:falsebut the S&F buffer still holds the id) it reportsForwarding.
Step 2 — run red.
Step 3 — implement. Change NotifyTarget.Send to return Task<string> (the NotificationId): create the GUID, build a NotificationSubmit (with SourceSiteId, SourceInstanceId = _instanceName, SiteEnqueuedAt = UtcNow), EnqueueAsync(Notification, "central", payloadJson). Add NotifyHelper.Status(string notificationId) returning a status record: query central via the site communication actor; if central returns Found:false and the id is still buffered in S&F, return status Forwarding. Keep the script-facing surface minimal (Send, Status).
Step 2 note: the Notify API is consumed by compiled scripts — confirm the script trust model / compilation still accepts the changed signature; update any script-API surface tests.
Step 4 — run green. Step 5 — commit (feat(notification-outbox): async Notify.Send with status handle).
Task 20: Central ingest routing
Files:
- Modify:
src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs - Test:
tests/ScadaLink.Communication.Tests/CentralCommunicationActorTests.cs
Step 1 — failing test. When CentralCommunicationActor receives a NotificationSubmit (sent site→central via ClusterClient to /user/central-communication), it forwards it to the notification-outbox singleton proxy and the ack flows back to the original Sender. Use a TestProbe for the outbox proxy.
Step 2 — run red.
Step 3 — implement. CentralCommunicationActor takes an optional outbox-proxy IActorRef (passed at construction by the Host, Task 21). Receive<NotificationSubmit>(m => _outboxProxy.Forward(m)) — Forward preserves the original sender so the NotificationSubmitAck returns to the site's ClusterClient.
Step 4 — run green. Step 5 — commit (feat(notification-outbox): route NotificationSubmit to the outbox actor).
Task 21: Host registration + appsettings
Files:
- Modify:
src/ScadaLink.Host/Actors/AkkaHostedService.cs(RegisterCentralActors) - Modify:
src/ScadaLink.Host/Program.cs(callAddNotificationOutbox;Configure<NotificationOutboxOptions>) - Modify:
src/ScadaLink.Host/appsettings.Central.json(ScadaLink:NotificationOutboxsection) - Modify:
src/ScadaLink.Host/appsettings.Site.json(site→central notification forward-retry interval, if not already covered by S&F config) - Test:
tests/ScadaLink.Host.Tests/if present, else verify via build + the integration test in Task 25
Step 1 — implement. In RegisterCentralActors: create the NotificationOutboxActor as a cluster singleton (ClusterSingletonManager.Props + ClusterSingletonProxy.Props, singleton name "notification-outbox", no explicit role — central nodes only run this role), passing IServiceProvider, NotificationOutboxOptions, the adapter dictionary, and a logger. Pass the singleton proxy ref into CentralCommunicationActor's Props.Create. In Program.cs central path, call builder.Services.AddNotificationOutbox() and services.Configure<NotificationOutboxOptions>(...GetSection(ServiceCollectionExtensions.OptionsSection)). Add the ScadaLink:NotificationOutbox block to appsettings.Central.json with the Task 10 defaults.
Step 2 — verify: dotnet build ScadaLink.slnx succeeds.
Step 3 — commit (feat(notification-outbox): register NotificationOutbox singleton in Host).
Phase E — Central UI
Task 22: CommunicationService outbox methods
Files:
- Modify:
src/ScadaLink.Communication/CommunicationService.cs - Test:
tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs(or the existing service test file)
Step 1 — failing tests. New methods QueryNotificationOutboxAsync, RetryNotificationAsync, DiscardNotificationAsync, GetNotificationKpisAsync each Ask the central outbox proxy and return the typed response. (These are central-side and do not go through SiteEnvelope — they talk to the local outbox proxy directly.) Test with a TestProbe for the proxy.
Step 2 — run red.
Step 3 — implement. Add an outbox-proxy IActorRef to CommunicationService (set by the Host like SetCommunicationActor). Each method Ask<TResponse>(request, _options.QueryTimeout).
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add CommunicationService outbox methods).
Task 23: Notification Outbox Blazor page + nav entry
Files:
- Create:
src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor - Modify:
src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor - Test:
tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs(bUnit)
Step 1 — failing test (bUnit). Render the page with a substituted CommunicationService returning a fixed KPI response and a page of NotificationSummary rows; assert the KPI tiles show the values and the table renders the rows; assert clicking Retry on a Parked row calls RetryNotificationAsync.
Step 2 — run red.
Step 3 — implement. Model the page on Components/Pages/Monitoring/ParkedMessages.razor: @page "/monitoring/notification-outbox", @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]. KPI tile row (Bootstrap card tiles like Health.razor) bound to GetNotificationKpisAsync; a filter card (status, type, source site, list, time range, stuck-only toggle, subject keyword); a table of NotificationSummary with stuck rows badged; Retry/Discard buttons on Parked rows using IDialogService.ConfirmAsync + ToastNotification. Add a NavLink to NavMenu.razor under the Deployment-role Monitoring section (href="/monitoring/notification-outbox").
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add Notification Outbox UI page).
Task 24: Health dashboard outbox KPI tiles
Files:
- Modify:
src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor - Test:
tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs(extend if present)
Step 1 — failing test (bUnit). With a substituted CommunicationService.GetNotificationKpisAsync, the Health page renders three headline outbox tiles: queue depth, stuck count, parked count.
Step 2 — run red.
Step 3 — implement. Add a "Notification Outbox" tile row to Health.razor, fetched on init / on the existing 10s polling timer, styled like the existing overview cards.
Step 4 — run green. Step 5 — commit (feat(notification-outbox): add outbox KPI tiles to Health dashboard).
Phase F — Integration & verification
Task 25: End-to-end integration test
Files:
- Create:
tests/ScadaLink.IntegrationTests/NotificationOutboxFlowTests.cs
Step 1 — failing test. Following the patterns in tests/ScadaLink.IntegrationTests/, exercise the flow with an in-memory/SQLite-backed ScadaLinkDbContext and a real NotificationOutboxActor: submit a NotificationSubmit → assert a Notifications row exists (Pending) → trigger a DispatchTick with a stub adapter that returns Success → assert the row is Delivered. Add a second case: stub adapter returns PermanentFailure → row Parked; then a RetryNotificationRequest → row back to Pending.
Step 2 — run red. Step 3 — make it pass (it should, if Phases A–D are correct; fix any wiring gaps found). Step 4 — commit (test(notification-outbox): end-to-end outbox flow integration test).
Task 26: Full build + suite verification
Files: none (verification only).
Step 1: dotnet build ScadaLink.slnx → must succeed with no errors.
Step 2: dotnet test ScadaLink.slnx → the whole suite must pass. Investigate and fix any regressions (notably in ScadaLink.NotificationService.Tests, ScadaLink.StoreAndForward.Tests, ScadaLink.SiteRuntime.Tests, ScadaLink.Communication.Tests — the docs/design changed the notification path and existing tests may assert old behavior; update them to the new design).
Step 3: If the docker cluster is used for smoke testing, note that bash docker/deploy.sh rebuilds the image — out of scope for this plan unless the user asks.
Step 4 — commit any test fixes (test(notification-outbox): update existing tests for the central-delivery model).
Done
The Notification Outbox feature is implemented end to end: site scripts enqueue notifications that store-and-forward to central, the NotificationOutboxActor singleton ingests them into the Notifications table and delivers them via the Email adapter with retry/parking, operators see KPIs and manage notifications from the Central UI, and the full test suite passes. Teams and other delivery adapters can be added later by implementing INotificationDeliveryAdapter and registering it — no other change required.