Files
scadalink-design/docs/plans/2026-05-23-audit-source-node.md
Joseph Doherty 9e5e32d0f2 docs(audit): add SourceNode column to AuditLog/Notifications/SiteCalls design + plan
- Adds SourceNode varchar(64) NULL to AuditLog, Notifications, and SiteCalls
  tables with role-name semantics: node-a/node-b for site rows (qualified by
  SourceSiteId), central-a/central-b for central direct-write rows.
- New IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc) index.
- Reframes CLAUDE.md from documentation-only to implementation project.
- Adds docs/plans/2026-05-23-audit-source-node.md + tasks.json companion.
2026-05-23 15:34:44 -04:00

932 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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%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/<project> --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
{
/// <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; }
}
```
```csharp
// 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):
```csharp
services.AddSingleton<INodeIdentityProvider, NodeIdentityProvider>();
```
**Step 5: Run tests + build**
```bash
dotnet test tests/<project> --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/<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**
```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 \
<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**
```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/<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 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/<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:
```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<string>(
name: "SourceNode",
table: "AuditLog",
type: "varchar(64)",
unicode: false,
maxLength: 64,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_AuditLog_Node_Occurred",
table: "AuditLog",
columns: new[] { "SourceNode", "OccurredAtUtc" });
```
`Down()` drops the index then the column.
**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/<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 14:** 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 14:** 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<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:
```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<NotificationSubmitAck>(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<string>? 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": "<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.
```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.