# Audit `SourceNode` Stamping — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Capture the cluster node of origin (`node-a` / `node-b` for site rows, `central-a` / `central-b` for central direct-write rows) on every `AuditLog`, `Notifications`, and `SiteCalls` row, end-to-end from the writing node through telemetry / reconciliation to the Central UI. **Architecture:** Introduce a single `INodeIdentityProvider` exposing the local node name from `NodeOptions.NodeName` (new config key, bound from `ScadaLink:Node:NodeName`). Stamp `SourceNode` at the *writing node* — site `SqliteAuditWriter` for site rows, `CentralAuditWriter` for central direct-writes — and carry it verbatim through the existing gRPC `AuditEventDto` / `SiteCallOperationalDto` envelopes (additive new field), the `NotificationSubmit` S&F payload, and the `CachedCallTelemetry` packet. EF Core migrations add the column to all three central tables; site-side SQLite schemas use the existing idempotent `PRAGMA table_info` + `ALTER TABLE ADD COLUMN` pattern. UI gets a new "Node" column + filter on the three grids. **Tech Stack:** .NET 8 / C# 12 sealed records, EF Core 8 (MS SQL on central, SQLite at sites), Akka.NET, Grpc.AspNetCore, Blazor Server, xUnit + Akka.TestKit + NSubstitute + Playwright. Migration naming: `YYYYMMDDHHmmss_DescriptiveTitle`. **Out of scope (deferred):** - Cross-table `SourceNode`-aware KPIs (e.g., "per-node stuck count"). The data is captured; dashboards stay site-keyed until a real ask lands. - Backfilling existing rows with a best-guess `SourceNode`. New rows get the column; legacy rows stay `NULL`. - Renaming `NodeHostname` (the Docker container hostname) — it stays as the diagnostic hostname; `NodeName` is the new semantic role-within-cluster name. --- ## Conventions - All file paths are relative to repo root (`/Users/dohertj2/Desktop/scadalink-design`). - TDD: red → green → commit. Failing test first, then implementation, then verify, then commit. - One coherent change per commit. Commit messages prefix with the affected slice: `feat(audit):`, `feat(notif-outbox):`, `feat(sitecall-audit):`, `chore(docker):`, etc. - After each task, run the targeted test set (`dotnet test --filter `) and the full solution build (`dotnet build ScadaLink.slnx`) before the commit. - For tasks whose migration name needs a timestamp, use `date -u +%Y%m%d%H%M%S` at the moment of execution. The exact name doesn't matter for correctness; just keep them monotonically ordered. --- ## Task 0: Branch + Snapshot **Files:** none (git only) **Step 1: Create feature branch** ```bash git checkout -b feature/audit-source-node git status ``` Expected: branch created, working tree shows only the prior design-doc edits + the modified `appsettings.*.json` files already in `git status`. **Step 2: Stash unrelated dirty files** `docker/central-node-{a,b}/appsettings.Central.json` and `src/ScadaLink.CentralUI/Components/Pages/Login.razor` are dirty from prior unrelated work — do **NOT** include them in this feature's commits. Verify with `git diff --stat` what's already modified, and either revert or stash: ```bash git diff --stat git stash push -- docker/central-node-a/appsettings.Central.json docker/central-node-b/appsettings.Central.json src/ScadaLink.CentralUI/Components/Pages/Login.razor ``` Expected: clean diff against the prior commit + the design-doc edits from this session. **Step 3: Baseline build + tests** ```bash dotnet build ScadaLink.slnx dotnet test tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj dotnet test tests/ScadaLink.SiteCallAudit.Tests/ScadaLink.SiteCallAudit.Tests.csproj ``` Expected: green. If any are red on `main`, **STOP** and surface that to the user — the plan assumes a green starting point. **Step 4: Commit the in-progress design-doc edits from this session** ```bash git add CLAUDE.md docs/requirements/Component-AuditLog.md docs/requirements/Component-NotificationOutbox.md docs/requirements/Component-SiteCallAudit.md docs/plans/2026-05-23-audit-source-node.md git commit -m "docs(audit): add SourceNode column to AuditLog/Notifications/SiteCalls design + plan" ``` --- ## Task 1: NodeOptions + INodeIdentityProvider **Files:** - Modify: `src/ScadaLink.Host/NodeOptions.cs` - Create: `src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs` - Create: `src/ScadaLink.Host/NodeIdentityProvider.cs` - Modify: `src/ScadaLink.Host/SiteServiceRegistration.cs` (and central registration if separate — check `CentralServiceRegistration.cs` if it exists) - Create test: `tests/ScadaLink.Host.Tests/NodeIdentityProviderTests.cs` (if Host.Tests doesn't exist, place under `tests/ScadaLink.AuditLog.Tests/Configuration/NodeIdentityProviderTests.cs` instead) **Step 1: Failing test — provider returns configured NodeName** ```csharp [Fact] public void NodeIdentityProvider_returns_configured_NodeName() { var opts = Options.Create(new NodeOptions { NodeName = "central-a", Role = "Central" }); var provider = new NodeIdentityProvider(opts); Assert.Equal("central-a", provider.NodeName); } [Fact] public void NodeIdentityProvider_returns_null_when_NodeName_unset() { var opts = Options.Create(new NodeOptions { NodeName = "", Role = "Central" }); var provider = new NodeIdentityProvider(opts); Assert.Null(provider.NodeName); } ``` Run: `dotnet test tests/ --filter NodeIdentityProvider` → expected FAIL (types don't exist yet). **Step 2: Add `NodeName` to `NodeOptions`** ```csharp public class NodeOptions { public string Role { get; set; } = string.Empty; public string NodeHostname { get; set; } = string.Empty; public string NodeName { get; set; } = string.Empty; // <— new public string? SiteId { get; set; } public int RemotingPort { get; set; } = 8081; public int GrpcPort { get; set; } = 8083; } ``` **Step 3: Create `INodeIdentityProvider` + implementation** ```csharp // src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs namespace ScadaLink.Commons.Interfaces.Services; public interface INodeIdentityProvider { /// /// Semantic role-within-cluster name of the local node — `node-a` / `node-b` /// for site nodes, `central-a` / `central-b` for central nodes. NULL when /// unconfigured (development/legacy hosts). /// string? NodeName { get; } } ``` ```csharp // src/ScadaLink.Host/NodeIdentityProvider.cs internal sealed class NodeIdentityProvider : INodeIdentityProvider { public NodeIdentityProvider(IOptions options) { var name = options.Value.NodeName; NodeName = string.IsNullOrWhiteSpace(name) ? null : name.Trim(); } public string? NodeName { get; } } ``` **Step 4: Register the singleton in DI** In `SiteServiceRegistration.cs` and `CentralServiceRegistration.cs` (or wherever `NodeOptions` is bound): ```csharp services.AddSingleton(); ``` **Step 5: Run tests + build** ```bash dotnet test tests/ --filter NodeIdentityProvider -v n dotnet build ScadaLink.slnx ``` Expected: PASS, solution builds. **Step 6: Commit** ```bash git add src/ScadaLink.Host/NodeOptions.cs \ src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs \ src/ScadaLink.Host/NodeIdentityProvider.cs \ src/ScadaLink.Host/SiteServiceRegistration.cs \ tests//NodeIdentityProviderTests.cs git commit -m "feat(host): add NodeName to NodeOptions + INodeIdentityProvider" ``` --- ## Task 2: Add `SourceNode` to `AuditEvent` record **Files:** - Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` - Modify: any direct constructors of `AuditEvent` (compile errors will surface them) - Modify: `tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs` (or the closest equivalent) — add a SourceNode round-trip assertion. **Step 1: Failing test** ```csharp [Fact] public void AuditEvent_carries_SourceNode_through_with_init() { var ev = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered, SourceSiteId = "site-a", SourceNode = "node-a", }; Assert.Equal("node-a", ev.SourceNode); } ``` Run: expected FAIL (property doesn't exist). **Step 2: Add `SourceNode` to the record** In `AuditEvent.cs`, add between `SourceSiteId` and `SourceInstanceId` (mirroring the design doc): ```csharp public string? SourceNode { get; init; } ``` **Step 3: Resolve compile errors** The record is `sealed` and used widely. Most usages will be init-only and won't break; positional constructors (if any) will. Fix them by adding `SourceNode = …` initializers OR by leaving `SourceNode = null` where the caller doesn't know the node yet (writer-level stamping happens in Task 9 + 10). **Step 4: Run + commit** ```bash dotnet test tests/ScadaLink.AuditLog.Tests --filter SourceNode -v n dotnet build ScadaLink.slnx git add src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs git commit -m "feat(audit): add SourceNode property to AuditEvent record" ``` --- ## Task 3: Add `SourceNode` to `SiteCallOperational` + `SiteCall` entity **Files:** - Modify: `src/ScadaLink.Commons/Types/SiteCallOperational.cs` - Modify: `src/ScadaLink.Commons/Entities/Audit/SiteCall.cs` - Modify: tests in `tests/ScadaLink.SiteCallAudit.Tests/` that build a `SiteCall` — extend at least one to assert SourceNode is carried. **Step 1: Failing test — `SiteCallOperational` constructed with SourceNode** ```csharp [Fact] public void SiteCallOperational_carries_SourceNode() { var op = new SiteCallOperational( TrackedOperationId: TrackedOperationId.New(), Channel: "ApiOutbound", Target: "ERP.GetOrder", SourceSite: "site-a", SourceNode: "node-a", // new positional arg Status: "Submitted", RetryCount: 0, LastError: null, HttpStatus: null, CreatedAtUtc: DateTime.UtcNow, UpdatedAtUtc: DateTime.UtcNow, TerminalAtUtc: null); Assert.Equal("node-a", op.SourceNode); } ``` Run: expected FAIL. **Step 2: Add `SourceNode` to `SiteCallOperational`** Insert `string? SourceNode` between `SourceSite` and `Status`. **Update all callers** — the C# compiler will list every site that constructs the record. Most are in: - `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs` - `src/ScadaLink.DataConnectionLayer/...` (cached DB write site) - `src/ScadaLink.Communication/...` (mappers) - `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` - All `tests/ScadaLink.SiteCallAudit.Tests/*` and `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs` For now, pass `SourceNode: null` at every existing call site — actual stamping comes in Task 11. **Step 3: Add `SourceNode` to `SiteCall` entity** Mirror in `src/ScadaLink.Commons/Entities/Audit/SiteCall.cs` as `public string? SourceNode { get; init; }`. **Step 4: Run + commit** ```bash dotnet build ScadaLink.slnx dotnet test tests/ScadaLink.SiteCallAudit.Tests --filter SourceNode -v n git add src/ScadaLink.Commons/Types/SiteCallOperational.cs \ src/ScadaLink.Commons/Entities/Audit/SiteCall.cs \ git commit -m "feat(sitecall-audit): add SourceNode to SiteCallOperational + SiteCall entity" ``` --- ## Task 4: Add `SourceNode` to `Notification` entity + `NotificationSubmit` message **Files:** - Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` - Modify: `src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs` - Modify: tests in `tests/ScadaLink.NotificationOutbox.Tests/` **Step 1: Failing test** ```csharp [Fact] public void NotificationSubmit_carries_SourceNode() { var submit = new NotificationSubmit( NotificationId: Guid.NewGuid().ToString("D"), ListName: "ops-team", Subject: "x", Body: "y", SourceSiteId: "site-a", SourceInstanceId: "instance-1", SourceScript: "OnAlarm", SourceNode: "node-a", // new SiteEnqueuedAt: DateTimeOffset.UtcNow); Assert.Equal("node-a", submit.SourceNode); } ``` Run: expected FAIL. **Step 2: Add `SourceNode` to both** In `Notification.cs`: `public string? SourceNode { get; set; }`. In `NotificationMessages.cs`, extend the record additively (defaulted optional positional arg so existing callers compile — keep behind the optional `OriginExecutionId`/`OriginParentExecutionId` to preserve the existing tail): ```csharp public record NotificationSubmit( string NotificationId, string ListName, string Subject, string Body, string SourceSiteId, string? SourceInstanceId, string? SourceScript, DateTimeOffset SiteEnqueuedAt, Guid? OriginExecutionId = null, Guid? OriginParentExecutionId = null, string? SourceNode = null); // new, tail ``` **Step 3: Run + commit** ```bash dotnet build ScadaLink.slnx dotnet test tests/ScadaLink.NotificationOutbox.Tests --filter SourceNode -v n git add src/ScadaLink.Commons/Entities/Notifications/Notification.cs \ src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs \ tests/ScadaLink.NotificationOutbox.Tests/.cs git commit -m "feat(notif-outbox): add SourceNode to Notification entity + NotificationSubmit" ``` --- ## Task 5: Add `source_node` to proto + update DTO mappers **Files:** - Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` - Modify: `src/ScadaLink.Communication/AuditEventDtoMapper.cs` (or wherever `ToDto` / `FromDto` live) - Modify: `src/ScadaLink.Communication/SiteCallOperationalDtoMapper.cs` (likewise) - Modify: `tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs` - Modify: equivalent SiteCallOperational proto round-trip tests **Step 1: Failing test — proto round-trip preserves SourceNode** ```csharp [Fact] public void AuditEventDto_round_trip_preserves_SourceNode() { var ev = new AuditEvent { /* … */ SourceNode = "node-a", SourceSiteId = "site-a" }; var dto = AuditEventDtoMapper.ToDto(ev); var back = AuditEventDtoMapper.FromDto(dto); Assert.Equal("node-a", back.SourceNode); } [Fact] public void AuditEventDto_round_trip_preserves_null_SourceNode() { var ev = new AuditEvent { /* … */ SourceNode = null }; var dto = AuditEventDtoMapper.ToDto(ev); var back = AuditEventDtoMapper.FromDto(dto); Assert.Null(back.SourceNode); } ``` Run: expected FAIL (compile error on `SourceNode` until mapper handles it). **Step 2: Extend proto additively** In `sitestream.proto`, **field numbers must be new and not reused**: ```proto message AuditEventDto { // … existing fields 1..21 unchanged … string source_node = 22; // empty string represents null } message SiteCallOperationalDto { // … existing fields 1..11 unchanged … string source_node = 12; // empty string represents null } ``` **Step 3: Regenerate + update mappers** `Grpc.Tools` regenerates on build. Update `AuditEventDtoMapper.ToDto`: ```csharp SourceNode = ev.SourceNode ?? string.Empty, ``` And `FromDto`: ```csharp SourceNode = string.IsNullOrEmpty(dto.SourceNode) ? null : dto.SourceNode, ``` Same pattern for `SiteCallOperationalDtoMapper`. **Step 4: Run + commit** ```bash dotnet build ScadaLink.slnx dotnet test tests/ScadaLink.Communication.Tests --filter SourceNode -v n git add src/ScadaLink.Communication/Protos/sitestream.proto \ src/ScadaLink.Communication/AuditEventDtoMapper.cs \ src/ScadaLink.Communication/SiteCallOperationalDtoMapper.cs \ tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs \ tests/ScadaLink.Communication.Tests/.cs git commit -m "feat(comm): add source_node field to AuditEventDto + SiteCallOperationalDto proto" ``` --- ## Task 6: EF migration — add `SourceNode` to `AuditLog` + index **Files:** - Create: `src/ScadaLink.ConfigurationDatabase/Migrations/_AddAuditLogSourceNode.cs` + `.Designer.cs` - Modify: `src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs` - Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` - Create test: `tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogSourceNodeMigrationTests.cs` **Step 1: Failing test (migration apply produces `SourceNode` column + index)** Pattern from existing `AddAuditLogTableMigrationTests.cs`. Apply migration against a fresh MS SQL test fixture; assert `INFORMATION_SCHEMA.COLUMNS` contains `SourceNode varchar(64) NULL` and `sys.indexes` contains `IX_AuditLog_Node_Occurred` with columns `(SourceNode, OccurredAtUtc)`. Run: expected FAIL. **Step 2: Add migration via EF CLI** From repo root: ```bash dotnet ef migrations add AddAuditLogSourceNode \ --project src/ScadaLink.ConfigurationDatabase \ --startup-project src/ScadaLink.Host \ --context ScadaLinkDbContext ``` Hand-edit the generated `Up()` / `Down()` to verify shape: ```csharp migrationBuilder.AddColumn( name: "SourceNode", table: "AuditLog", type: "varchar(64)", unicode: false, maxLength: 64, nullable: true); // IMPORTANT: AuditLog is partitioned on ps_AuditLog_Month(OccurredAtUtc). // `migrationBuilder.CreateIndex(...)` lands the index on [PRIMARY], which breaks // `ALTER TABLE … SWITCH PARTITION` (the purge mechanism). Match the pattern used // by the other `IX_AuditLog_*` indexes (see 20260520142214_AddAuditLogTable.cs // and 20260521184044_AddAuditLogExecutionId.cs) — raw SQL with the partition // scheme spelled out. Keep the fluent `HasIndex(...).HasDatabaseName(...)` in // the EF configuration so the model snapshot stays in sync. migrationBuilder.Sql(@" CREATE NONCLUSTERED INDEX IX_AuditLog_Node_Occurred ON dbo.AuditLog (SourceNode, OccurredAtUtc) ON ps_AuditLog_Month(OccurredAtUtc);"); ``` `Down()` drops the index (`IF EXISTS DROP INDEX … ON dbo.AuditLog`, raw SQL) then the column. You will *also* need to extend `AuditLogRepository.SwitchOutPartitionAsync`'s staging-table CREATE to include `SourceNode varchar(64) NULL` in the final ordinal position. `SWITCH PARTITION` rejects schema mismatches between live and staging — without this, the PartitionPurge integration tests fail. **Step 3: Update EF configuration** In `AuditLogEntityTypeConfiguration.cs`, mirror the design doc: ```csharp builder.Property(e => e.SourceNode).HasColumnType("varchar(64)").HasMaxLength(64); builder.HasIndex(e => new { e.SourceNode, e.OccurredAtUtc }) .HasDatabaseName("IX_AuditLog_Node_Occurred"); ``` **Step 4: Run + commit** ```bash dotnet build ScadaLink.slnx dotnet test tests/ScadaLink.ConfigurationDatabase.Tests --filter AuditLogSourceNode -v n git add src/ScadaLink.ConfigurationDatabase/Migrations/_AddAuditLogSourceNode.cs \ src/ScadaLink.ConfigurationDatabase/Migrations/_AddAuditLogSourceNode.Designer.cs \ src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs \ src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs \ tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogSourceNodeMigrationTests.cs git commit -m "feat(db): add SourceNode column + IX_AuditLog_Node_Occurred index to AuditLog" ``` --- ## Task 7: EF migration — add `SourceNode` to `Notifications` **Files:** - Create: `src/ScadaLink.ConfigurationDatabase/Migrations/_AddNotificationSourceNode.cs` + `.Designer.cs` - Modify: `src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs` - Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs` - Create test: `tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationSourceNodeMigrationTests.cs` **Step 1–4:** Mirror Task 6 for the `Notifications` table. No new index (the spec says index only on `AuditLog`). ```bash git commit -m "feat(db): add SourceNode column to Notifications" ``` --- ## Task 8: EF migration — add `SourceNode` to `SiteCalls` **Files:** equivalents under `SiteCalls`. No new index. **Step 1–4:** Mirror Task 6. Configuration file is `SiteCallEntityTypeConfiguration.cs`. ```bash git commit -m "feat(db): add SourceNode column to SiteCalls" ``` --- ## Task 9: Site SQLite `AuditLog` — add `SourceNode` column (idempotent upgrade) **Files:** - Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` - Modify: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` - Modify: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs` **Step 1: Failing test — schema includes SourceNode AND old DBs are upgraded** ```csharp [Fact] public async Task Initialize_creates_AuditLog_with_SourceNode_column() { using var writer = new SqliteAuditWriter(/*…in-memory…*/); var cols = await ReadColumnsAsync("AuditLog"); Assert.Contains("SourceNode", cols); } [Fact] public async Task Initialize_adds_SourceNode_to_pre_existing_schema() { // 1. open a SQLite file and create the OLD schema (no SourceNode) // 2. open SqliteAuditWriter against the same file // 3. assert SourceNode column now exists via PRAGMA table_info } ``` Run: expected FAIL. **Step 2: Update `InitializeSchema`** Add `SourceNode TEXT NULL` to the `CREATE TABLE IF NOT EXISTS AuditLog (...)` DDL. Add a second `PRAGMA table_info`-based upgrade block matching the existing `ExecutionId` / `ParentExecutionId` pattern: ```csharp if (!columns.Contains("SourceNode", StringComparer.OrdinalIgnoreCase)) { using var cmd = conn.CreateCommand(); cmd.CommandText = "ALTER TABLE AuditLog ADD COLUMN SourceNode TEXT NULL;"; cmd.ExecuteNonQuery(); } ``` **Step 3: Update INSERT statement to include SourceNode** In the parameterized batch insert SQL (lines ~270-284), add the column + parameter. **Step 4: Run + commit** ```bash dotnet test tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs --filter SourceNode -v n dotnet build ScadaLink.slnx git add src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs \ tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs \ tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs git commit -m "feat(audit): add SourceNode column to site SQLite AuditLog (idempotent upgrade)" ``` --- ## Task 10: Site SQLite `OperationTracking` — add `SourceNode` column **Files:** - Modify: `src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs` - Modify: tests under `tests/ScadaLink.SiteRuntime.Tests/Tracking/` (or closest) **Step 1: Failing test** — same pattern as Task 9, asserting the `OperationTracking` table grows a `SourceNode TEXT NULL` column on both fresh and pre-existing DBs. **Step 2: Add column to CREATE TABLE + idempotent PRAGMA-based ALTER** ```sql CREATE TABLE IF NOT EXISTS OperationTracking ( -- ...existing columns... SourceNode TEXT NULL ); ``` Plus the same PRAGMA upgrade block. **Step 3: Update `RecordEnqueueAsync` signature** Accept `string? sourceNode` and pass through to INSERT. **Step 4: Run + commit** ```bash git commit -m "feat(site-runtime): add SourceNode column to OperationTracking + thread through RecordEnqueueAsync" ``` --- ## Task 11: Stamp `SourceNode` at the site `SqliteAuditWriter` **Files:** - Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` - Modify: DI registration that wires `SqliteAuditWriter` (likely `src/ScadaLink.AuditLog/AuditLogServiceCollectionExtensions.cs` or equivalent) - Modify: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs` **Step 1: Failing test** ```csharp [Fact] public async Task WriteAsync_stamps_SourceNode_from_INodeIdentityProvider_when_event_has_none() { var nodeId = Substitute.For(); nodeId.NodeName.Returns("node-a"); using var writer = new SqliteAuditWriter(/*…*/, nodeId); await writer.WriteAsync(new AuditEvent { /*…*/ SourceNode = null }); var rows = await ReadAllAsync(); Assert.Equal("node-a", rows.Single().SourceNode); } [Fact] public async Task WriteAsync_preserves_caller_provided_SourceNode() { var nodeId = Substitute.For(); nodeId.NodeName.Returns("node-a"); using var writer = new SqliteAuditWriter(/*…*/, nodeId); await writer.WriteAsync(new AuditEvent { /*…*/ SourceNode = "node-z" }); var rows = await ReadAllAsync(); Assert.Equal("node-z", rows.Single().SourceNode); // caller wins (e.g. reconciliation) } ``` Run: expected FAIL. **Step 2: Implementation** Inject `INodeIdentityProvider` into `SqliteAuditWriter`. In `WriteAsync` / batch flush, **before** binding parameters: ```csharp var stamped = ev.SourceNode is null ? ev with { SourceNode = _nodeIdentity.NodeName } : ev; ``` **Step 3: Run + commit** ```bash git commit -m "feat(audit): stamp SourceNode at site SqliteAuditWriter from INodeIdentityProvider" ``` --- ## Task 12: Stamp `SourceNode` at central `CentralAuditWriter` **Files:** - Modify: `src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs` - Modify: `tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs` **Step 1: Failing test** — mirror Task 11 but for the central writer (Inbound API / Notification Outbox dispatcher path). **Step 2: Implementation** — inject `INodeIdentityProvider`, stamp before `repo.InsertIfNotExistsAsync`. Same "caller wins" semantics. **Step 3: Update `AuditLogRepository.InsertIfNotExistsAsync` to persist `SourceNode`** This is in `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — extend the parameterized INSERT. **Step 4: Run + commit** ```bash git commit -m "feat(audit): stamp SourceNode at CentralAuditWriter + persist via AuditLogRepository" ``` --- ## Task 13: Site → central — carry `SourceNode` on `NotificationSubmit` **Files:** - Modify: site code that constructs `NotificationSubmit` (likely under `src/ScadaLink.SiteRuntime/Scripts/` or `src/ScadaLink.NotificationService/Site/...`) - Modify: S&F buffer schema if NotificationSubmit is serialized to SQLite (check `src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs` for a notification-specific column — likely the whole DTO is serialized as a blob, no schema change needed) - Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` `HandleSubmit` to copy `SourceNode` into the `Notification` row - Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs` (or wherever) `InsertIfNotExistsAsync` SQL - Modify: tests in `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs` **Step 1: Failing test — central persists SourceNode from NotificationSubmit** ```csharp [Fact] public async Task HandleSubmit_persists_SourceNode_from_payload() { var submit = new NotificationSubmit(/*…*/, SourceNode: "node-b"); actor.Tell(submit); await ExpectMsgAsync(m => m.Accepted); var row = await repo.GetByIdAsync(submit.NotificationId); Assert.Equal("node-b", row.SourceNode); } ``` **Step 2: Implementation** - Site: inject `INodeIdentityProvider` into the call site that builds `NotificationSubmit`, pass `SourceNode = nodeIdentity.NodeName`. - Central: extend `HandleSubmit` to copy `submit.SourceNode` onto the `Notification` row; extend the repo INSERT to persist. **Step 3: Run + commit** ```bash git commit -m "feat(notif-outbox): carry + persist SourceNode end-to-end via NotificationSubmit" ``` --- ## Task 14: Site → central — carry `SourceNode` on `SiteCallOperational` + cached telemetry **Files:** - Modify: site emitters of `SiteCallOperational` — `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs`, `src/ScadaLink.DataConnectionLayer/...` (cached DB write), and the S&F retry path that emits `Attempted` packets - Modify: `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` (and any `SiteCallAuditIngestActor` that maps `SiteCallOperational` → `SiteCall`) - Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs` — extend monotonic upsert SQL to include `SourceNode` - Modify: tests in `tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs` and `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs` **Step 1: Failing test — central persists `SourceNode` on upsert; subsequent upsert with same id keeps SourceNode set even if newer packet has it null** ```csharp [Fact] public async Task Upsert_persists_SourceNode_on_first_insert_and_preserves_on_status_advance() { var id = TrackedOperationId.New(); await repo.UpsertAsync(new SiteCall { TrackedOperationId = id, /*…*/ SourceNode = "node-a", Status = "Submitted" }); await repo.UpsertAsync(new SiteCall { TrackedOperationId = id, /*…*/ SourceNode = null, Status = "Delivered" }); var row = await repo.GetByIdAsync(id); Assert.Equal("node-a", row.SourceNode); Assert.Equal("Delivered", row.Status); } ``` **Step 2: Implementation** - Site emitters: inject `INodeIdentityProvider`, pass `SourceNode = nodeIdentity.NodeName` on construction. - Repository: include `SourceNode` in the INSERT branch. In the conditional monotonic UPDATE branch, use `SourceNode = COALESCE(@SourceNode, SourceNode)` so later packets with a null don't blank out a previously-stamped value. **Step 3: Run + commit** ```bash git commit -m "feat(sitecall-audit): carry + persist SourceNode end-to-end via cached telemetry" ``` --- ## Task 15: Central UI — add Node column + filter to AuditLog grid **Files:** - Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` + `.razor.cs` (add column entry to `AllColumns`) - Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (add a text or multi-select Node filter) - Modify: `src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs` (extend the query/filter model) - Modify: tests in `tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs` - Modify: `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs` — assert the Node column renders **Step 1: Failing test — query service supports `SourceNode` filter** ```csharp [Fact] public async Task Query_filters_by_SourceNode() { // arrange 2 rows: one with SourceNode=node-a, one with node-b var rows = await service.QueryAsync(new AuditLogQuery { SourceNodes = new[] { "node-a" } }); Assert.Single(rows); } ``` **Step 2: Implementation** - Extend `AuditLogQuery` with `IReadOnlyList? SourceNodes` (multi-select like `Sites`). - Extend the LINQ filter (`q.SourceNodes is { Count: > 0 } ? source.Where(r => q.SourceNodes.Contains(r.SourceNode)) : source`). - Add a new column descriptor `("Node", row => row.SourceNode ?? "—")` between `Site` and `Channel` in `AllColumns`. - Add a multi-select filter chip in `AuditFilterBar.razor`. Populate node options by `SELECT DISTINCT SourceNode FROM AuditLog WHERE SourceNode IS NOT NULL` (cached for 60s). **Step 3: Run + commit** ```bash git commit -m "feat(ui): add Node column + filter to AuditLog grid" ``` --- ## Task 16: Central UI — add Node column + filter to Notifications grid **Files:** mirror Task 15 for `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor` and its query service. No filter populate-from-DB required if scope is per-site already; a free-text Node filter is acceptable for v1. ```bash git commit -m "feat(ui): add Node column + filter to NotificationOutbox grid" ``` --- ## Task 17: Central UI — add Node column + filter to SiteCalls grid **Files:** mirror Task 15 for `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor`. ```bash git commit -m "feat(ui): add Node column + filter to SiteCalls grid" ``` --- ## Task 18: Docker `appsettings` — set `NodeName` on all 8 nodes **Files:** - `docker/central-node-a/appsettings.Central.json` → `"NodeName": "central-a"` - `docker/central-node-b/appsettings.Central.json` → `"NodeName": "central-b"` - `docker/site-a-node-a/appsettings.Site.json` → `"NodeName": "node-a"` - `docker/site-a-node-b/appsettings.Site.json` → `"NodeName": "node-b"` - `docker/site-b-node-a/appsettings.Site.json` → `"NodeName": "node-a"` - `docker/site-b-node-b/appsettings.Site.json` → `"NodeName": "node-b"` - `docker/site-c-node-a/appsettings.Site.json` → `"NodeName": "node-a"` - `docker/site-c-node-b/appsettings.Site.json` → `"NodeName": "node-b"` Add `"NodeName": ""` to the existing `"Node": { … }` object in each file. **Do not** disturb the two `central-*` files that show up dirty in pre-existing `git status` from before this branch — re-apply this change cleanly after stashing. ```bash git add docker/central-node-a/appsettings.Central.json docker/central-node-b/appsettings.Central.json docker/site-a-node-a/appsettings.Site.json docker/site-a-node-b/appsettings.Site.json docker/site-b-node-a/appsettings.Site.json docker/site-b-node-b/appsettings.Site.json docker/site-c-node-a/appsettings.Site.json docker/site-c-node-b/appsettings.Site.json git commit -m "chore(docker): set NodeName on all 8 cluster nodes" ``` --- ## Task 19: Full-solution build + targeted test sweep **Step 1: Build** ```bash dotnet build ScadaLink.slnx ``` Expected: 0 errors, 0 warnings. **Step 2: Run the touched test projects** ```bash dotnet test tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj dotnet test tests/ScadaLink.SiteCallAudit.Tests/ScadaLink.SiteCallAudit.Tests.csproj dotnet test tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj ``` Expected: all green. If anything fails, fix it before proceeding. **Step 3: Full solution test (sanity)** ```bash dotnet test ScadaLink.slnx --no-build ``` --- ## Task 20: Docker redeploy + smoke verify **Step 1: Rebuild + redeploy the cluster** ```bash bash docker/deploy.sh ``` Expected: image rebuilt, all 8 containers up. Watch for migration apply on central startup. **Step 2: CLI smoke — generate one inbound, one notification, one cached call; verify SourceNode populated** ```bash # Trigger something that emits each kind. The exact CLI commands depend on # scripts deployed at the time; at minimum: # 1. POST to inbound API → produces an InboundRequest row # 2. Run a script that calls Notify.Send → produces NotifySend + NotifyDeliver # 3. Run a script that calls ExternalSystem.CachedCall → produces CachedSubmit/Forwarded/Attempted/Delivered # Then check SourceNode is populated: docker exec scadalink-mssql /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'YourStrong!Passw0rd' -Q "SELECT TOP 20 Channel, Kind, SourceSiteId, SourceNode, Status FROM scadalink.dbo.AuditLog ORDER BY OccurredAtUtc DESC" ``` Expected: - `InboundRequest` rows: `SourceSiteId IS NULL`, `SourceNode IN ('central-a','central-b')`. - `NotifyDeliver` rows: `SourceSiteId IS NULL`, `SourceNode IN ('central-a','central-b')`. - Site-originated rows: `SourceSiteId = 'site-a'`, `SourceNode IN ('node-a','node-b')`. **Step 3: UI smoke** Open `http://localhost:9000`, log in as `multi-role` / `password`, hit each of: - Audit Log → confirm "Node" column shows values, confirm Node filter narrows results. - Notifications → same. - Site Calls → same. **Step 4: Final commit (if anything had to be tweaked during smoke)** ```bash git add -A git status # verify nothing surprising git commit -m "chore(audit): smoke-verify SourceNode end-to-end across cluster" ``` --- ## Acceptance Criteria (the whole-plan checklist) - [ ] Every audit row written from this commit forward carries `SourceNode` populated (`central-a/b` for central direct-write, `node-a/b` for site rows). - [ ] Every new `Notifications` and `SiteCalls` row carries `SourceNode` from the site. - [ ] `IX_AuditLog_Node_Occurred` exists in central MS SQL. - [ ] Site SQLite `AuditLog` and `OperationTracking` tables both have `SourceNode TEXT NULL`; existing site DBs are upgraded idempotently on startup. - [ ] Proto `AuditEventDto.source_node = 22` and `SiteCallOperationalDto.source_node = 12` exist; no field numbers reused. - [ ] Central UI Audit Log, Notifications, and Site Calls pages all display a "Node" column and support filtering by it. - [ ] All test projects green. - [ ] Cluster comes up clean via `bash docker/deploy.sh`; CLI smoke confirms expected node names land in the central tables. - [ ] Design docs (already committed in Task 0) match the implementation.