diff --git a/docs/components/SiteRuntime.md b/docs/components/SiteRuntime.md
index e5675159..a114056c 100644
--- a/docs/components/SiteRuntime.md
+++ b/docs/components/SiteRuntime.md
@@ -12,7 +12,7 @@ The component code lives in `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/`:
- `Scripts/` — `ScriptCompilationService`, `ScriptExecutionScheduler`, `SharedScriptLibrary`, `ScriptRuntimeContext`, `ScopeAccessors`, `TriggerExpressionGlobals`.
- `Streaming/` — `SiteStreamManager` (the site-wide Akka broadcast stream).
- `Persistence/` — `SiteStorageService` (raw SQLite via `Microsoft.Data.Sqlite`), `SiteStorageInitializer`.
-- `Repositories/` — `SiteExternalSystemRepository`, `SiteNotificationRepository`.
+- `Repositories/` — `SiteExternalSystemRepository`. (The `SiteNotificationRepository` variant was removed 2026-07-10, arch-review 08 §1.3/#23, because notification config is central-only and never lives on a site.)
- `Tracking/` — `OperationTrackingStore`, `OperationTrackingOptions`.
`ServiceCollectionExtensions.AddSiteRuntime(connectionString)` registers all singletons; the `Host` calls it and wires the `DeploymentManagerActor` cluster singleton separately via `AkkaHostedService`.
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs
index 60800328..52a4680e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs
@@ -799,6 +799,12 @@ public class SiteStorageService
/// idempotent and is invoked on every artifact apply / deploy so existing exposure
/// is cleared, not just future writes. The tables themselves are retained (the
/// schema is harmless once empty); only their contents are removed.
+ ///
+ /// The site-side write paths (StoreNotificationListAsync/StoreSmtpConfigurationAsync)
+ /// and the SiteNotificationRepository were removed 2026-07-10 (arch-review 08 §1.3/#23)
+ /// because notification config is central-only and must never be written to a site — this
+ /// purge is the sole remaining touchpoint, kept only to scrub DBs written by older builds;
+ /// do not reintroduce site-side notification writes.
///
/// A task that completes when both tables have been emptied.
public async Task PurgeCentralOnlyNotificationConfigAsync()
@@ -813,80 +819,6 @@ public class SiteStorageService
await command.ExecuteNonQueryAsync();
}
- ///
- /// Stores or updates a notification list.
- ///
- /// The name of the notification list.
- /// List of recipient email addresses.
- /// A task that completes when the notification list has been stored or updated.
- public async Task StoreNotificationListAsync(string name, IReadOnlyList recipientEmails)
- {
- await using var connection = new SqliteConnection(_connectionString);
- await connection.OpenAsync();
-
- await using var command = connection.CreateCommand();
- command.CommandText = @"
- INSERT INTO notification_lists (name, recipient_emails, updated_at)
- VALUES (@name, @emails, @updatedAt)
- ON CONFLICT(name) DO UPDATE SET
- recipient_emails = excluded.recipient_emails,
- updated_at = excluded.updated_at";
-
- command.Parameters.AddWithValue("@name", name);
- command.Parameters.AddWithValue("@emails", System.Text.Json.JsonSerializer.Serialize(recipientEmails));
- command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
-
- await command.ExecuteNonQueryAsync();
- }
-
- // ── SMTP Configuration CRUD ──
-
- ///
- /// Stores or updates an SMTP configuration.
- ///
- /// The name of the SMTP configuration.
- /// SMTP server hostname.
- /// SMTP server port.
- /// Authentication mode ('None', 'BasicAuth', 'OAuth2').
- /// Email address used as the sender.
- /// Username for authentication, if applicable.
- /// Password for authentication, if applicable.
- /// OAuth2 configuration JSON, if applicable.
- /// A task that completes when the SMTP configuration has been stored or updated.
- public async Task StoreSmtpConfigurationAsync(
- string name, string server, int port, string authMode, string fromAddress,
- string? username, string? password, string? oauthConfig)
- {
- await using var connection = new SqliteConnection(_connectionString);
- await connection.OpenAsync();
-
- await using var command = connection.CreateCommand();
- command.CommandText = @"
- INSERT INTO smtp_configurations (name, server, port, auth_mode, from_address, username, password, oauth_config, updated_at)
- VALUES (@name, @server, @port, @authMode, @fromAddress, @username, @password, @oauthConfig, @updatedAt)
- ON CONFLICT(name) DO UPDATE SET
- server = excluded.server,
- port = excluded.port,
- auth_mode = excluded.auth_mode,
- from_address = excluded.from_address,
- username = excluded.username,
- password = excluded.password,
- oauth_config = excluded.oauth_config,
- updated_at = excluded.updated_at";
-
- command.Parameters.AddWithValue("@name", name);
- command.Parameters.AddWithValue("@server", server);
- command.Parameters.AddWithValue("@port", port);
- command.Parameters.AddWithValue("@authMode", authMode);
- command.Parameters.AddWithValue("@fromAddress", fromAddress);
- command.Parameters.AddWithValue("@username", (object?)username ?? DBNull.Value);
- command.Parameters.AddWithValue("@password", (object?)password ?? DBNull.Value);
- command.Parameters.AddWithValue("@oauthConfig", (object?)oauthConfig ?? DBNull.Value);
- command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
-
- await command.ExecuteNonQueryAsync();
- }
-
// ── Data Connection Definition CRUD ──
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteNotificationRepository.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteNotificationRepository.cs
deleted file mode 100644
index c69171bc..00000000
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteNotificationRepository.cs
+++ /dev/null
@@ -1,300 +0,0 @@
-using System.Text.Json;
-using Microsoft.Data.Sqlite;
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
-using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
-using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
-
-namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
-
-///
-/// Site-side read-only implementation of
-/// backed by the local SQLite database via .
-/// Write operations throw because site-local
-/// artifacts are managed exclusively through deployment from Central.
-///
-public class SiteNotificationRepository : INotificationRepository
-{
- private readonly SiteStorageService _storage;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The site storage service for database access.
- public SiteNotificationRepository(SiteStorageService storage)
- {
- _storage = storage ?? throw new ArgumentNullException(nameof(storage));
- }
-
- // ── NotificationList (read) ──
-
- ///
- public async Task GetListByNameAsync(
- string name, CancellationToken cancellationToken = default)
- {
- await using var connection = CreateConnection();
- await connection.OpenAsync(cancellationToken);
-
- await using var command = connection.CreateCommand();
- command.CommandText = @"
- SELECT name, recipient_emails
- FROM notification_lists
- WHERE name = @name";
- command.Parameters.AddWithValue("@name", name);
-
- await using var reader = await command.ExecuteReaderAsync(cancellationToken);
- if (!await reader.ReadAsync(cancellationToken))
- return null;
-
- return MapNotificationList(reader);
- }
-
- ///
- public async Task> GetAllNotificationListsAsync(
- CancellationToken cancellationToken = default)
- {
- await using var connection = CreateConnection();
- await connection.OpenAsync(cancellationToken);
-
- await using var command = connection.CreateCommand();
- command.CommandText = "SELECT name, recipient_emails FROM notification_lists";
-
- var results = new List();
- await using var reader = await command.ExecuteReaderAsync(cancellationToken);
- while (await reader.ReadAsync(cancellationToken))
- {
- results.Add(MapNotificationList(reader));
- }
-
- return results;
- }
-
- ///
- public async Task GetNotificationListByIdAsync(
- int id, CancellationToken cancellationToken = default)
- {
- // The SQLite table is keyed by name, not by integer ID.
- // Scan all rows and match on the synthetic ID derived from the name.
- var all = await GetAllNotificationListsAsync(cancellationToken);
- return all.FirstOrDefault(l => l.Id == id);
- }
-
- // ── NotificationRecipient (read) ──
-
- ///
- public async Task> GetRecipientsByListIdAsync(
- int notificationListId, CancellationToken cancellationToken = default)
- {
- // Find the parent list to get its name, then parse the recipient_emails JSON.
- var list = await GetNotificationListByIdAsync(notificationListId, cancellationToken);
- if (list is null)
- return Array.Empty();
-
- await using var connection = CreateConnection();
- await connection.OpenAsync(cancellationToken);
-
- await using var command = connection.CreateCommand();
- command.CommandText = @"
- SELECT recipient_emails
- FROM notification_lists
- WHERE name = @name";
- command.Parameters.AddWithValue("@name", list.Name);
-
- var json = (string?)await command.ExecuteScalarAsync(cancellationToken);
- if (string.IsNullOrWhiteSpace(json))
- return Array.Empty();
-
- return ParseRecipientEmails(json, notificationListId);
- }
-
- ///
- public async Task GetRecipientByIdAsync(
- int id, CancellationToken cancellationToken = default)
- {
- // Scan all lists and their recipients to find the matching synthetic ID.
- var lists = await GetAllNotificationListsAsync(cancellationToken);
- foreach (var list in lists)
- {
- var recipients = await GetRecipientsByListIdAsync(list.Id, cancellationToken);
- var match = recipients.FirstOrDefault(r => r.Id == id);
- if (match is not null)
- return match;
- }
-
- return null;
- }
-
- // ── SmtpConfiguration (read) ──
-
- ///
- public async Task> GetAllSmtpConfigurationsAsync(
- CancellationToken cancellationToken = default)
- {
- await using var connection = CreateConnection();
- await connection.OpenAsync(cancellationToken);
-
- await using var command = connection.CreateCommand();
- command.CommandText = @"
- SELECT name, server, port, auth_mode, from_address, username, password, oauth_config
- FROM smtp_configurations";
-
- var results = new List();
- await using var reader = await command.ExecuteReaderAsync(cancellationToken);
- while (await reader.ReadAsync(cancellationToken))
- {
- results.Add(MapSmtpConfiguration(reader));
- }
-
- return results;
- }
-
- ///
- public async Task GetSmtpConfigurationByIdAsync(
- int id, CancellationToken cancellationToken = default)
- {
- var all = await GetAllSmtpConfigurationsAsync(cancellationToken);
- return all.FirstOrDefault(s => s.Id == id);
- }
-
- // ── Write operations (not supported on site) ──
-
- ///
- public Task AddNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task UpdateNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task DeleteNotificationListAsync(int id, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task AddRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task UpdateRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task DeleteRecipientAsync(int id, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task AddSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task UpdateSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task DeleteSmtpConfigurationAsync(int id, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- // ── SmsConfiguration (central-only — never deployed to or served from the site) ──
-
- ///
- public Task GetSmsConfigurationAsync(CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task GetSmsConfigurationByIdAsync(int id, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task> GetAllSmsConfigurationsAsync(CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task AddSmsConfigurationAsync(SmsConfiguration smsConfiguration, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task UpdateSmsConfigurationAsync(SmsConfiguration smsConfiguration, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task DeleteSmsConfigurationAsync(int id, CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- ///
- public Task SaveChangesAsync(CancellationToken cancellationToken = default)
- => throw new NotSupportedException("Managed via artifact deployment from Central");
-
- // ── Private helpers ──
-
- ///
- /// Creates a new SQLite connection against the site database via
- /// instead of
- /// reaching into its private connection-string field via reflection.
- ///
- private SqliteConnection CreateConnection() => _storage.CreateConnection();
-
- private static NotificationList MapNotificationList(SqliteDataReader reader)
- {
- var name = reader.GetString(0);
- var list = new NotificationList(name)
- {
- Id = GenerateSyntheticId(name)
- };
-
- // Eagerly populate Recipients from the JSON column.
- var emailsJson = reader.GetString(1);
- var recipients = ParseRecipientEmails(emailsJson, list.Id);
- foreach (var r in recipients)
- {
- list.Recipients.Add(r);
- }
-
- return list;
- }
-
- private static IReadOnlyList ParseRecipientEmails(
- string json, int notificationListId)
- {
- try
- {
- var emails = JsonSerializer.Deserialize>(json,
- new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
-
- if (emails is null)
- return Array.Empty();
-
- return emails.Select(email => new NotificationRecipient(
- name: email,
- emailAddress: email)
- {
- Id = GenerateSyntheticId($"{notificationListId}:{email}"),
- NotificationListId = notificationListId
- }).ToList();
- }
- catch (JsonException)
- {
- return Array.Empty();
- }
- }
-
- private static SmtpConfiguration MapSmtpConfiguration(SqliteDataReader reader)
- {
- var name = reader.GetString(0);
- return new SmtpConfiguration(
- host: reader.GetString(1),
- authType: reader.GetString(3),
- fromAddress: reader.GetString(4))
- {
- Id = GenerateSyntheticId(name),
- Port = reader.GetInt32(2),
- Credentials = reader.IsDBNull(5) ? null : reader.GetString(5),
- TlsMode = reader.IsDBNull(7) ? null : reader.GetString(7)
- };
- }
-
- ///
- /// Generates a stable positive integer ID from a string name.
- /// Uses a deterministic FNV-1a hash rather than ,
- /// which is randomized per process on .NET Core and would change every restart.
- ///
- private static int GenerateSyntheticId(string name) => SyntheticId.From(name);
-}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs
index b5db5550..20b612da 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs
@@ -62,7 +62,6 @@ public static class ServiceCollectionExtensions
// Site-local repository implementations backed by SQLite
services.AddScoped();
- services.AddScoped();
// Notify-and-fetch: typed HttpClient for fetching deployment configs from central.
services.AddHttpClient()
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs
index ba94e831..f054b651 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs
@@ -424,7 +424,8 @@ public class SiteCompositionRootTests : IDisposable
public static IEnumerable