refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -2,11 +2,11 @@
> **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.
**Goal:** Build the central Notification Outbox feature in the ScadaBridge `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.
**Architecture:** A new `ZB.MOM.WW.ScadaBridge.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`.
**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: `ZB.MOM.WW.ScadaBridge.slnx`.
**Authoritative design:** `docs/plans/notif.md` and `docs/requirements/Component-NotificationOutbox.md`. Read both before starting.
@@ -16,16 +16,16 @@
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 to `new List<T>()`. No data annotations.
- **EF mapping (ConfigurationDatabase):** Fluent `IEntityTypeConfiguration<T>` classes in `src/ScadaLink.ConfigurationDatabase/Configurations/`, auto-applied by `ApplyConfigurationsFromAssembly`. Enums stored as strings via `.HasConversion<string>()`. Add a `DbSet<T>` to `ScadaLinkDbContext`.
- **Repositories:** Interface in `src/ScadaLink.Commons/Interfaces/Repositories/`, implementation in `src/ScadaLink.ConfigurationDatabase/Repositories/`. Inject `ScadaLinkDbContext`, use `_context.Set<T>()`, expose explicit `SaveChangesAsync`. Register in `ConfigurationDatabase/ServiceCollectionExtensions.cs` with `AddScoped`.
- **Migrations:** `dotnet ef migrations add <Name> --project src/ScadaLink.ConfigurationDatabase` — timestamp-named. Applied via `MigrationHelper.ApplyOrValidateMigrationsAsync` (auto in dev).
- **Message contracts (Commons):** `record` types in `src/ScadaLink.Commons/Messages/<Area>/`, named positional params, additive-only evolution.
- **Options pattern:** `<Component>Options` class owned by the component project; component's `ServiceCollectionExtensions.Add<Component>()` calls `services.AddOptions<T>().BindConfiguration("ScadaLink:<Section>")`; Host also `services.Configure<T>(...)`. Config lives in `appsettings.Central.json` / `appsettings.Site.json`.
- **Actors:** No Akka.DI framework. Dependencies passed via `Props.Create(() => new XActor(...))`. Actors that need scoped services take `IServiceProvider` and call `CreateScope()`. Cluster singletons use `ClusterSingletonManager.Props` + `ClusterSingletonProxy.Props`, created in `src/ScadaLink.Host/Actors/AkkaHostedService.cs`.
- **Tests:** xUnit, NSubstitute, built-in `Assert`. One `tests/ScadaLink.<Component>.Tests/` project per `src/` project. Actor tests inherit `Akka.TestKit.Xunit2.TestKit`. Repository tests use SQLite in-memory (`DataSource=:memory:`, `OpenConnection()` + `EnsureCreated()`, `IDisposable`). Blazor tests inherit bUnit `BunitContext`. Test naming: `Method_Scenario_Result`.
- **Run tests:** whole suite `dotnet test ScadaLink.slnx`; single project `dotnet test tests/ScadaLink.<X>.Tests/ScadaLink.<X>.Tests.csproj`; single test `--filter "FullyQualifiedName~<Class>.<Method>"`.
- **Build:** `dotnet build ScadaLink.slnx`.
- **Entities (Commons):** POCOs in `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/<Area>/`. Auto-properties, parameterized constructor with null checks, navigation collections initialised to `new List<T>()`. No data annotations.
- **EF mapping (ConfigurationDatabase):** Fluent `IEntityTypeConfiguration<T>` classes in `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/`, auto-applied by `ApplyConfigurationsFromAssembly`. Enums stored as strings via `.HasConversion<string>()`. Add a `DbSet<T>` to `ScadaBridgeDbContext`.
- **Repositories:** Interface in `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/`, implementation in `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/`. Inject `ScadaBridgeDbContext`, use `_context.Set<T>()`, expose explicit `SaveChangesAsync`. Register in `ConfigurationDatabase/ServiceCollectionExtensions.cs` with `AddScoped`.
- **Migrations:** `dotnet ef migrations add <Name> --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — timestamp-named. Applied via `MigrationHelper.ApplyOrValidateMigrationsAsync` (auto in dev).
- **Message contracts (Commons):** `record` types in `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/<Area>/`, named positional params, additive-only evolution.
- **Options pattern:** `<Component>Options` class owned by the component project; component's `ServiceCollectionExtensions.Add<Component>()` calls `services.AddOptions<T>().BindConfiguration("ScadaBridge:<Section>")`; Host also `services.Configure<T>(...)`. Config lives in `appsettings.Central.json` / `appsettings.Site.json`.
- **Actors:** No Akka.DI framework. Dependencies passed via `Props.Create(() => new XActor(...))`. Actors that need scoped services take `IServiceProvider` and call `CreateScope()`. Cluster singletons use `ClusterSingletonManager.Props` + `ClusterSingletonProxy.Props`, created in `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs`.
- **Tests:** xUnit, NSubstitute, built-in `Assert`. One `tests/ZB.MOM.WW.ScadaBridge.<Component>.Tests/` project per `src/` project. Actor tests inherit `Akka.TestKit.Xunit2.TestKit`. Repository tests use SQLite in-memory (`DataSource=:memory:`, `OpenConnection()` + `EnsureCreated()`, `IDisposable`). Blazor tests inherit bUnit `BunitContext`. Test naming: `Method_Scenario_Result`.
- **Run tests:** whole suite `dotnet test ZB.MOM.WW.ScadaBridge.slnx`; single project `dotnet test tests/ZB.MOM.WW.ScadaBridge.<X>.Tests/ZB.MOM.WW.ScadaBridge.<X>.Tests.csproj`; single test `--filter "FullyQualifiedName~<Class>.<Method>"`.
- **Build:** `dotnet build ZB.MOM.WW.ScadaBridge.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>`.
@@ -39,9 +39,9 @@ These were confirmed by exploring the existing codebase. Follow them in every ta
### 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 a `Types/` folder)
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/NotificationType.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/NotificationStatus.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/NotificationEnumTests.cs` (create if the test project lacks a `Types/` folder)
**Step 1 — failing test.** Assert the enums expose exactly the expected members:
```csharp
@@ -62,14 +62,14 @@ public void NotificationType_HasEmail()
```
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 2 — run red:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter "FullyQualifiedName~NotificationEnumTests"` → FAIL (types don't exist).
**Step 3 — implement.**
```csharp
// NotificationType.cs — namespace ScadaLink.Commons.Types.Enums
// NotificationType.cs — namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
public enum NotificationType { Email } // Teams and others added later
// NotificationStatus.cs — namespace ScadaLink.Commons.Types.Enums
// NotificationStatus.cs — namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
public enum NotificationStatus { Pending, Retrying, Delivered, Parked, Discarded }
```
@@ -77,7 +77,7 @@ public enum NotificationStatus { Pending, Retrying, Delivered, Parked, Discarded
**Step 5 — commit:**
```bash
git add src/ScadaLink.Commons/Types/Enums/NotificationType.cs src/ScadaLink.Commons/Types/Enums/NotificationStatus.cs tests/ScadaLink.Commons.Tests/Types/NotificationEnumTests.cs
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/NotificationType.cs src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/NotificationStatus.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/NotificationEnumTests.cs
git commit -m "feat(notification-outbox): add NotificationType and NotificationStatus enums"
```
@@ -86,8 +86,8 @@ git commit -m "feat(notification-outbox): add NotificationType and NotificationS
### Task 2: `Notification` entity POCO
**Files:**
- Create: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs`
- Test: `tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Notifications/Notification.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.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:
```csharp
@@ -110,7 +110,7 @@ public void Constructor_NullListName_Throws()
**Step 3 — implement.** Match the `Notifications` table schema in `notif.md`:
```csharp
namespace ScadaLink.Commons.Entities.Notifications;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
public class Notification
{
@@ -153,9 +153,9 @@ public class Notification
### 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)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Notifications/NotificationList.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/NotificationConfiguration.cs` (`NotificationListConfiguration`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.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:
```csharp
@@ -182,9 +182,9 @@ public async Task NotificationList_PersistsType()
### Task 4: `Notification` EF configuration + DbSet
**Files:**
- Create: `src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs`
- Modify: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs` (add `DbSet<Notification>`)
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs` (add `DbSet<Notification>`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.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`.
@@ -215,7 +215,7 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
}
}
```
Add `public DbSet<Notification> Notifications => Set<Notification>();` to `ScadaLinkDbContext`.
Add `public DbSet<Notification> Notifications => Set<Notification>();` to `ScadaBridgeDbContext`.
**Step 4 — run green. Step 5 — commit** (`feat(notification-outbox): add Notification EF configuration and DbSet`).
@@ -224,10 +224,10 @@ Add `public DbSet<Notification> Notifications => Set<Notification>();` to `Scada
### 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` (register `AddScoped`)
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs` (register `AddScoped`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryTests.cs`
**Step 1 — failing tests.** Cover the operations the outbox actor needs:
- `InsertIfNotExistsAsync` inserts a new row and returns `true`; a second call with the same `NotificationId` returns `false` and does not duplicate (idempotency key).
@@ -264,17 +264,17 @@ public interface INotificationOutboxRepository
### Task 6: EF migration
**Files:**
- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/<timestamp>_AddNotificationsTable.cs` (generated)
- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/<timestamp>_AddNotificationsTable.cs` (generated)
**Step 1 — generate:**
```bash
dotnet ef migrations add AddNotificationsTable --project src/ScadaLink.ConfigurationDatabase
dotnet ef migrations add AddNotificationsTable --project src/ZB.MOM.WW.ScadaBridge.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 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 ZB.MOM.WW.ScadaBridge.slnx` must succeed.
**Step 3 — run:** `dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj` → PASS.
**Step 3 — run:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.csproj` → PASS.
**Step 4 — commit** (`feat(notification-outbox): add Notifications table migration`).
@@ -285,14 +285,14 @@ This also picks up the `NotificationList.Type` column from Task 3.
### Task 7: Site↔central notification message contracts
**Files:**
- Create: `src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs`
- Test: `tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Notification/NotificationMessages.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.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`:
**Step 3 — implement.** Namespace `ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification`:
```csharp
// Site → Central: submit a notification for central delivery (fire-and-forget with ack).
public record NotificationSubmit(
@@ -319,8 +319,8 @@ public record NotificationStatusResponse(
### Task 8: Outbox UI query/action contracts
**Files:**
- Create: `src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs`
- Test: `tests/ScadaLink.Commons.Tests/Messages/NotificationOutboxQueriesTests.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Notification/NotificationOutboxQueries.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NotificationOutboxQueriesTests.cs`
**Step 1 — failing test.** Construction test as in Task 7.
@@ -359,31 +359,31 @@ public record NotificationKpiResponse(
## Phase C — NotificationOutbox project + delivery
### Task 9: Create the `ScadaLink.NotificationOutbox` project
### Task 9: Create the `ZB.MOM.WW.ScadaBridge.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)
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj`
- Create: `tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.csproj`
- Modify: `ZB.MOM.WW.ScadaBridge.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 1 — create the projects.** Copy the `.csproj` shape from `src/ZB.MOM.WW.ScadaBridge.NotificationService/ZB.MOM.WW.ScadaBridge.NotificationService.csproj` (same `TargetFramework`, central-managed package versions via `Directory.Packages.props`). The src project references `ZB.MOM.WW.ScadaBridge.Commons` and Akka packages (`Akka`, `Akka.Cluster.Tools`). The test project mirrors `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/` (xUnit, NSubstitute, `Akka.TestKit.Xunit2`) and references the new src project. Add both `<Project>` entries to `ZB.MOM.WW.ScadaBridge.slnx`.
**Step 2 — add a placeholder test** so the test project is non-empty:
```csharp
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 3 — verify:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` succeeds; `dotnet test tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.csproj` → PASS.
**Step 4 — commit** (`feat(notification-outbox): scaffold ScadaLink.NotificationOutbox project`).
**Step 4 — commit** (`feat(notification-outbox): scaffold ZB.MOM.WW.ScadaBridge.NotificationOutbox project`).
---
### Task 10: `NotificationOutboxOptions`
**Files:**
- Create: `src/ScadaLink.NotificationOutbox/NotificationOutboxOptions.cs`
- Test: `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxOptionsTests.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxOptions.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxOptionsTests.cs`
**Step 1 — failing test.** Assert the defaults.
@@ -409,9 +409,9 @@ public class 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`
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/INotificationDeliveryAdapter.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/DeliveryOutcome.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/DeliveryOutcomeTests.cs`
**Step 1 — failing test.** Assert `DeliveryOutcome` factory methods produce the right classification.
@@ -442,9 +442,9 @@ public interface INotificationDeliveryAdapter
### Task 12: `EmailNotificationDeliveryAdapter`
**Files:**
- Create: `src/ScadaLink.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs`
- Modify: `src/ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj` (reference `ScadaLink.NotificationService` for `ISmtpClientWrapper`)
- Test: `tests/ScadaLink.NotificationOutbox.Tests/Delivery/EmailNotificationDeliveryAdapterTests.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj` (reference `ZB.MOM.WW.ScadaBridge.NotificationService` for `ISmtpClientWrapper`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.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`, `ResolvedTargets` lists the recipient addresses.
@@ -454,7 +454,7 @@ public interface INotificationDeliveryAdapter
**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 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/ZB.MOM.WW.ScadaBridge.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`).
@@ -463,9 +463,9 @@ public interface INotificationDeliveryAdapter
### 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`
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Messages/InternalMessages.cs` (actor-internal tick messages)
- Test: `tests/ZB.MOM.WW.ScadaBridge.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 calls `InsertIfNotExistsAsync` with a `Notification` whose fields map from the message, `Status = Pending`, `CreatedAt` set; replies `NotificationSubmitAck(NotificationId, Accepted: true, null)` to `Sender`.
@@ -483,8 +483,8 @@ public interface INotificationDeliveryAdapter
### Task 14: `NotificationOutboxActor` — dispatcher loop
**Files:**
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs`
- Test: `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs`
**Step 1 — failing tests** (TestKit, with a registered set of `INotificationDeliveryAdapter` and a mocked repo):
- On a `DispatchTick`, the actor calls `GetDueAsync`, and for each row invokes the adapter for its `Type`.
@@ -505,8 +505,8 @@ public interface INotificationDeliveryAdapter
### Task 15: `NotificationOutboxActor` — query, retry, discard, KPIs
**Files:**
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs`
- Test: `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs`
**Step 1 — failing tests** (TestKit):
- `NotificationOutboxQueryRequest` → actor calls `QueryAsync`, replies `NotificationOutboxQueryResponse` with mapped `NotificationSummary` rows; `IsStuck` true when `Status` is `Pending`/`Retrying` and `CreatedAt` older than `options.StuckAgeThreshold`.
@@ -526,8 +526,8 @@ public interface INotificationDeliveryAdapter
### Task 16: Daily purge job
**Files:**
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs`
- Test: `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs`
**Step 1 — failing test.** On a `PurgeTick`, the actor calls `DeleteTerminalOlderThanAsync(now options.TerminalRetention)`.
@@ -542,14 +542,14 @@ public interface INotificationDeliveryAdapter
### Task 17: `AddNotificationOutbox` DI extension
**Files:**
- Create: `src/ScadaLink.NotificationOutbox/ServiceCollectionExtensions.cs`
- Test: `tests/ScadaLink.NotificationOutbox.Tests/ServiceRegistrationTests.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.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 3 — implement.** `public const string OptionsSection = "ScadaBridge: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`).
@@ -560,9 +560,9 @@ public interface INotificationDeliveryAdapter
### Task 18: Retarget the site S&F notification handler to forward to central
**Files:**
- Modify: `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs` and/or the site registration that wires the `Notification` category 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)
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs` and/or the site registration that wires the `Notification` category delivery handler
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs` (where the notification handler is registered)
- Test: `tests/ZB.MOM.WW.ScadaBridge.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).
@@ -577,8 +577,8 @@ public interface INotificationDeliveryAdapter
### 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)
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs` (`NotifyHelper`, `NotifyTarget`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/` (Notify API tests)
**Step 1 — failing tests.**
- `Notify.To("list").Send("subj","body")` generates a GUID `NotificationId`, enqueues a `StoreAndForwardCategory.Notification` message into `StoreAndForwardService` (target `"central"`, payload = serialized `NotificationSubmit`), and returns the `NotificationId` string immediately.
@@ -597,8 +597,8 @@ public interface INotificationDeliveryAdapter
### Task 20: Central ingest routing
**Files:**
- Modify: `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs`
- Test: `tests/ScadaLink.Communication.Tests/CentralCommunicationActorTests.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.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.
@@ -613,15 +613,15 @@ public interface INotificationDeliveryAdapter
### Task 21: Host registration + appsettings
**Files:**
- Modify: `src/ScadaLink.Host/Actors/AkkaHostedService.cs` (`RegisterCentralActors`)
- Modify: `src/ScadaLink.Host/Program.cs` (call `AddNotificationOutbox`; `Configure<NotificationOutboxOptions>`)
- Modify: `src/ScadaLink.Host/appsettings.Central.json` (`ScadaLink:NotificationOutbox` section)
- 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
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs` (`RegisterCentralActors`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (call `AddNotificationOutbox`; `Configure<NotificationOutboxOptions>`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json` (`ScadaBridge:NotificationOutbox` section)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json` (site→central notification forward-retry interval, if not already covered by S&F config)
- Test: `tests/ZB.MOM.WW.ScadaBridge.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 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 `ScadaBridge:NotificationOutbox` block to `appsettings.Central.json` with the Task 10 defaults.
**Step 2 — verify:** `dotnet build ScadaLink.slnx` succeeds.
**Step 2 — verify:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` succeeds.
**Step 3 — commit** (`feat(notification-outbox): register NotificationOutbox singleton in Host`).
@@ -632,8 +632,8 @@ public interface INotificationDeliveryAdapter
### 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)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.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.
@@ -648,9 +648,9 @@ public interface INotificationDeliveryAdapter
### 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)
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor`
- Test: `tests/ZB.MOM.WW.ScadaBridge.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`.
@@ -665,8 +665,8 @@ public interface INotificationDeliveryAdapter
### 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)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor`
- Test: `tests/ZB.MOM.WW.ScadaBridge.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.
@@ -683,9 +683,9 @@ public interface INotificationDeliveryAdapter
### Task 25: End-to-end integration test
**Files:**
- Create: `tests/ScadaLink.IntegrationTests/NotificationOutboxFlowTests.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.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 1 — failing test.** Following the patterns in `tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/`, exercise the flow with an in-memory/SQLite-backed `ScadaBridgeDbContext` 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 AD are correct; fix any wiring gaps found). **Step 4 — commit** (`test(notification-outbox): end-to-end outbox flow integration test`).
@@ -695,9 +695,9 @@ public interface INotificationDeliveryAdapter
**Files:** none (verification only).
**Step 1:** `dotnet build ScadaLink.slnx` → must succeed with no errors.
**Step 1:** `dotnet build ZB.MOM.WW.ScadaBridge.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 2:** `dotnet test ZB.MOM.WW.ScadaBridge.slnx` → the whole suite must pass. Investigate and fix any regressions (notably in `ZB.MOM.WW.ScadaBridge.NotificationService.Tests`, `ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests`, `ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests`, `ZB.MOM.WW.ScadaBridge.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.
@@ -707,10 +707,10 @@ public interface INotificationDeliveryAdapter
## Follow-ups (post-merge, not blocking)
- **Remove the now-dead site-side `AddNotificationService()` (from Task 19 review).** After Task 19, the site script runtime no longer resolves `INotificationDeliveryService` (it enqueues into the Store-and-Forward engine instead). `src/ScadaLink.Host/SiteServiceRegistration.cs` still calls `AddNotificationService()`. Task 21 (Host registration) should drop it from the site path — `NotificationService` is now central-only.
- **Re-align the Central UI script sandbox `Notify` API (from Task 19 review).** `SandboxNotifyTarget.Send` in `src/ScadaLink.CentralUI/ScriptAnalysis/` still returns `Task<NotificationResult>` and has no `Status` method, while the production `NotifyTarget.Send` now returns `Task<string>` plus `Notify.Status`. A script that test-runs cleanly in the sandbox would not compile against the real runtime. The sandbox `Notify` surface should be rewritten to match production so the test-run feature stays faithful.
- **Remove the now-dead site-side `AddNotificationService()` (from Task 19 review).** After Task 19, the site script runtime no longer resolves `INotificationDeliveryService` (it enqueues into the Store-and-Forward engine instead). `src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs` still calls `AddNotificationService()`. Task 21 (Host registration) should drop it from the site path — `NotificationService` is now central-only.
- **Re-align the Central UI script sandbox `Notify` API (from Task 19 review).** `SandboxNotifyTarget.Send` in `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/` still returns `Task<NotificationResult>` and has no `Status` method, while the production `NotifyTarget.Send` now returns `Task<string>` plus `Notify.Status`. A script that test-runs cleanly in the sandbox would not compile against the real runtime. The sandbox `Notify` surface should be rewritten to match production so the test-run feature stays faithful.
- **Populate `SourceScript` on outbound notifications (from Task 19 review).** `NotifyTarget.Send` currently passes `SourceScript: null` — the executing script name is not threaded down to the `NotifyHelper`. The payload field and the forwarder already carry it end to end; only the enqueue side needs the wiring.
- **Share the SMTP helpers (from Task 12 review).** `EmailNotificationDeliveryAdapter` reimplements `ClassifySmtpError`/`SmtpErrorClass`, `ValidateAddresses`, and a `ScrubCredentials` helper because the originals are `internal` to `ScadaLink.NotificationService`. To avoid divergence (especially in the security-relevant credential redaction and the SMTP 4xx/5xx classification policy), promote `CredentialRedactor` to `public`, extract a `public static SmtpErrorClassifier`, and make `ValidateAddresses` shared — then have the adapter call them and delete the duplicates. The project reference already exists, so this is low-cost.
- **Share the SMTP helpers (from Task 12 review).** `EmailNotificationDeliveryAdapter` reimplements `ClassifySmtpError`/`SmtpErrorClass`, `ValidateAddresses`, and a `ScrubCredentials` helper because the originals are `internal` to `ZB.MOM.WW.ScadaBridge.NotificationService`. To avoid divergence (especially in the security-relevant credential redaction and the SMTP 4xx/5xx classification policy), promote `CredentialRedactor` to `public`, extract a `public static SmtpErrorClassifier`, and make `ValidateAddresses` shared — then have the adapter call them and delete the duplicates. The project reference already exists, so this is low-cost.
## Done