Tidies flagged by code review on the T6/T7/T8 migration bundle: - Add `.IsUnicode(false)` to the three SourceNode EF property mappings to match every other ASCII varchar column on the same entities. Physical column was already `varchar(64)` because `HasColumnType` wins, but the EF model metadata flag was inconsistent. - Add `unicode: false` to the three AddColumn<string> calls in the migrations + their Designer snapshots so the historical snapshots match the model. - Update the model snapshot to carry IsUnicode(false) on each SourceNode entry. - Document the SELECT-list invariant on SiteCallAuditRepository.QueryAsync: EF Core's FromSqlInterpolated requires every entity-tracked column in the result set, so future SiteCall columns must extend the list too. - Amend plan Task 6 Step 2 to document the partition-aligned raw-SQL index recipe and the staging-table sync requirement.
36 KiB
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 stayNULL. - Renaming
NodeHostname(the Docker container hostname) — it stays as the diagnostic hostname;NodeNameis 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 <project> --filter <name>) 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%Sat 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
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:
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
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
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 — checkCentralServiceRegistration.csif it exists) - Create test:
tests/ScadaLink.Host.Tests/NodeIdentityProviderTests.cs(if Host.Tests doesn't exist, place undertests/ScadaLink.AuditLog.Tests/Configuration/NodeIdentityProviderTests.csinstead)
Step 1: Failing test — provider returns configured NodeName
[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/<project> --filter NodeIdentityProvider → expected FAIL (types don't exist yet).
Step 2: Add NodeName to NodeOptions
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
// src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs
namespace ScadaLink.Commons.Interfaces.Services;
public interface INodeIdentityProvider
{
/// <summary>
/// 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).
/// </summary>
string? NodeName { get; }
}
// src/ScadaLink.Host/NodeIdentityProvider.cs
internal sealed class NodeIdentityProvider : INodeIdentityProvider
{
public NodeIdentityProvider(IOptions<NodeOptions> 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):
services.AddSingleton<INodeIdentityProvider, NodeIdentityProvider>();
Step 5: Run tests + build
dotnet test tests/<project> --filter NodeIdentityProvider -v n
dotnet build ScadaLink.slnx
Expected: PASS, solution builds.
Step 6: Commit
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/<project>/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
[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):
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
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 aSiteCall— extend at least one to assert SourceNode is carried.
Step 1: Failing test — SiteCallOperational constructed with SourceNode
[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.cssrc/ScadaLink.DataConnectionLayer/...(cached DB write site)src/ScadaLink.Communication/...(mappers)src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs- All
tests/ScadaLink.SiteCallAudit.Tests/*andtests/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
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 \
<updated callers>
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
[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):
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
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/<modified>.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 whereverToDto/FromDtolive) - 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
[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:
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:
SourceNode = ev.SourceNode ?? string.Empty,
And FromDto:
SourceNode = string.IsNullOrEmpty(dto.SourceNode) ? null : dto.SourceNode,
Same pattern for SiteCallOperationalDtoMapper.
Step 4: Run + commit
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/<sitecall mapper 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/<ts>_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:
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:
migrationBuilder.AddColumn<string>(
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:
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
dotnet build ScadaLink.slnx
dotnet test tests/ScadaLink.ConfigurationDatabase.Tests --filter AuditLogSourceNode -v n
git add src/ScadaLink.ConfigurationDatabase/Migrations/<ts>_AddAuditLogSourceNode.cs \
src/ScadaLink.ConfigurationDatabase/Migrations/<ts>_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/<ts>_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).
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.
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
[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:
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
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
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
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(likelysrc/ScadaLink.AuditLog/AuditLogServiceCollectionExtensions.csor equivalent) - Modify:
tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs
Step 1: Failing test
[Fact]
public async Task WriteAsync_stamps_SourceNode_from_INodeIdentityProvider_when_event_has_none()
{
var nodeId = Substitute.For<INodeIdentityProvider>();
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<INodeIdentityProvider>();
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:
var stamped = ev.SourceNode is null ? ev with { SourceNode = _nodeIdentity.NodeName } : ev;
Step 3: Run + commit
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
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 undersrc/ScadaLink.SiteRuntime/Scripts/orsrc/ScadaLink.NotificationService/Site/...) - Modify: S&F buffer schema if NotificationSubmit is serialized to SQLite (check
src/ScadaLink.StoreAndForward/StoreAndForwardStorage.csfor a notification-specific column — likely the whole DTO is serialized as a blob, no schema change needed) - Modify:
src/ScadaLink.NotificationOutbox/NotificationOutboxActor.csHandleSubmitto copySourceNodeinto theNotificationrow - Modify:
src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs(or wherever)InsertIfNotExistsAsyncSQL - Modify: tests in
tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs
Step 1: Failing test — central persists SourceNode from NotificationSubmit
[Fact]
public async Task HandleSubmit_persists_SourceNode_from_payload()
{
var submit = new NotificationSubmit(/*…*/, SourceNode: "node-b");
actor.Tell(submit);
await ExpectMsgAsync<NotificationSubmitAck>(m => m.Accepted);
var row = await repo.GetByIdAsync(submit.NotificationId);
Assert.Equal("node-b", row.SourceNode);
}
Step 2: Implementation
- Site: inject
INodeIdentityProviderinto the call site that buildsNotificationSubmit, passSourceNode = nodeIdentity.NodeName. - Central: extend
HandleSubmitto copysubmit.SourceNodeonto theNotificationrow; extend the repo INSERT to persist.
Step 3: Run + commit
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 emitsAttemptedpackets - Modify:
src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs(and anySiteCallAuditIngestActorthat mapsSiteCallOperational→SiteCall) - Modify:
src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs— extend monotonic upsert SQL to includeSourceNode - Modify: tests in
tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.csandtests/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
[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, passSourceNode = nodeIdentity.NodeNameon construction. - Repository: include
SourceNodein the INSERT branch. In the conditional monotonic UPDATE branch, useSourceNode = COALESCE(@SourceNode, SourceNode)so later packets with a null don't blank out a previously-stamped value.
Step 3: Run + commit
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 toAllColumns) - 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
[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
AuditLogQuerywithIReadOnlyList<string>? SourceNodes(multi-select likeSites). - 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 ?? "—")betweenSiteandChannelinAllColumns. - Add a multi-select filter chip in
AuditFilterBar.razor. Populate node options bySELECT DISTINCT SourceNode FROM AuditLog WHERE SourceNode IS NOT NULL(cached for 60s).
Step 3: Run + commit
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.
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.
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": "<value>" 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.
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
dotnet build ScadaLink.slnx
Expected: 0 errors, 0 warnings.
Step 2: Run the touched test projects
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)
dotnet test ScadaLink.slnx --no-build
Task 20: Docker redeploy + smoke verify
Step 1: Rebuild + redeploy the cluster
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
# 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:
InboundRequestrows:SourceSiteId IS NULL,SourceNode IN ('central-a','central-b').NotifyDeliverrows: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)
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
SourceNodepopulated (central-a/bfor central direct-write,node-a/bfor site rows). - Every new
NotificationsandSiteCallsrow carriesSourceNodefrom the site. IX_AuditLog_Node_Occurredexists in central MS SQL.- Site SQLite
AuditLogandOperationTrackingtables both haveSourceNode TEXT NULL; existing site DBs are upgraded idempotently on startup. - Proto
AuditEventDto.source_node = 22andSiteCallOperationalDto.source_node = 12exist; 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.