From 9e5e32d0f2c51f01b3245442391fd1c166e29362 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 15:34:44 -0400 Subject: [PATCH] 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. --- CLAUDE.md | 24 +- docs/plans/2026-05-23-audit-source-node.md | 931 ++++++++++++++++++ ...2026-05-23-audit-source-node.md.tasks.json | 27 + docs/requirements/Component-AuditLog.md | 10 +- .../Component-NotificationOutbox.md | 1 + docs/requirements/Component-SiteCallAudit.md | 4 + 6 files changed, 986 insertions(+), 11 deletions(-) create mode 100644 docs/plans/2026-05-23-audit-source-node.md create mode 100644 docs/plans/2026-05-23-audit-source-node.md.tasks.json diff --git a/CLAUDE.md b/CLAUDE.md index 4fe1684..75eccee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,17 +1,21 @@ -# ScadaLink Design Documentation Project +# ScadaLink Implementation Project -This project contains design documentation for a distributed SCADA system built on Akka.NET. The documents describe a hub-and-spoke architecture with a central cluster and multiple site clusters. +This is the full **implementation** project for ScadaLink — a distributed SCADA system built on Akka.NET in a hub-and-spoke architecture (one central cluster + multiple site clusters). It contains source code, tests, deployable docker topology, and the design documentation that the code implements. The design docs are the spec; `src/` is the binary. + +When a change is requested, the default assumption is: update the design doc *and* the code *and* the tests *and* (if it ships) the docker deploy — together, in one session, with `git diff` review before committing. ## Project Structure +- `src/` — C#/.NET implementation, one project per component (e.g. `ScadaLink.AuditLog`, `ScadaLink.NotificationOutbox`, `ScadaLink.SiteCallAudit`, `ScadaLink.CentralUI`, `ScadaLink.Host`, …). Solution file: `ScadaLink.slnx`. +- `tests/` — Test projects (unit + integration). +- `docker/` — 8-node cluster topology (2 central + 3 sites), `deploy.sh`, per-node `appsettings.*.json`. See [`docker/README.md`](docker/README.md) for setup, ports, and management commands. Rebuild + redeploy with `bash docker/deploy.sh`. +- `infra/` — Docker Compose for local test services (LDAP, MS SQL, OPC UA, SMTP, REST API, Traefik). - `README.md` — Master index with component table and architecture diagrams. - `docs/requirements/HighLevelReqs.md` — Complete high-level requirements covering all functional areas. -- `docs/requirements/Component-*.md` — Individual component design documents (one per component). +- `docs/requirements/Component-*.md` — Individual component design documents (one per component) — the spec the code implements. - `docs/test_infra/test_infra.md` — Master test infrastructure doc (OPC UA, LDAP, MS SQL, SMTP, REST API, Traefik). -- `docs/plans/` — Design decision documents from refinement sessions. +- `docs/plans/` — Design decision and implementation-plan documents from refinement sessions. - `AkkaDotNet/` — Akka.NET reference documentation and best practices notes. -- `infra/` — Docker Compose and config files for local test services. -- `docker/` — Docker infrastructure for the 8-node cluster topology (2 central + 3 sites). See [`docker/README.md`](docker/README.md) for cluster setup, port allocation, and management commands. ## Document Conventions @@ -31,10 +35,11 @@ This project contains design documentation for a distributed SCADA system built ## Editing Rules -- Edit documents in place. Do not create copies or backup files. -- When a change affects multiple documents, update all affected documents in the same session. +- Edit documents and code in place. Do not create copies or backup files. +- When a change affects multiple documents or projects, update them all in the same session — design doc, entities/repos, actors/services, UI, tests, migrations, and deploy config travel together. - Use `git diff` to review changes before committing. -- Commit related changes together with a descriptive message summarizing the design decision. +- Commit related changes together with a descriptive message summarizing the design decision and the implementation slice. +- After non-trivial code changes, build (`dotnet build ScadaLink.slnx`) and run relevant tests before declaring done; for cluster-runtime changes, rebuild the image with `bash docker/deploy.sh`. ## Current Component List (23 components) @@ -140,6 +145,7 @@ This project contains design documentation for a distributed SCADA system built - Audit-write failure NEVER aborts the user-facing action — audit is best-effort, the action's own success/failure path is authoritative. - 365-day central retention with monthly partition-switch purge; 7-day site SQLite retention with a hard `ForwardState` invariant (no row purged until forwarded or reconciled). - Append-only enforced via DB roles (writer role has INSERT only, no UPDATE/DELETE); hash-chain tamper evidence and Parquet archival are deferred to v1.x. +- Node-of-origin is captured alongside site-of-origin: `SourceNode` (`varchar(64)` NULL) on `AuditLog`, `Notifications`, and `SiteCalls` — `node-a`/`node-b` for site rows (qualified by `SourceSiteId`/`SourceSite`), `central-a`/`central-b` for central direct-write rows. Stamped at the writing node, carried verbatim through telemetry + reconciliation, and indexed via `IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)` on `AuditLog`. - Central UI: new top-level **Audit** nav group + Audit Log page, with drill-ins from Notifications, Site Calls, External Systems, Inbound API Keys, Sites, and Instances. ### Security & Auth diff --git a/docs/plans/2026-05-23-audit-source-node.md b/docs/plans/2026-05-23-audit-source-node.md new file mode 100644 index 0000000..4c6e278 --- /dev/null +++ b/docs/plans/2026-05-23-audit-source-node.md @@ -0,0 +1,931 @@ +# 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); + +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/_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. diff --git a/docs/plans/2026-05-23-audit-source-node.md.tasks.json b/docs/plans/2026-05-23-audit-source-node.md.tasks.json new file mode 100644 index 0000000..0d6db80 --- /dev/null +++ b/docs/plans/2026-05-23-audit-source-node.md.tasks.json @@ -0,0 +1,27 @@ +{ + "planPath": "docs/plans/2026-05-23-audit-source-node.md", + "tasks": [ + {"id": 1, "subject": "Task 0: Branch + Snapshot", "status": "pending"}, + {"id": 2, "subject": "Task 1: NodeOptions.NodeName + INodeIdentityProvider", "status": "pending", "blockedBy": [1]}, + {"id": 3, "subject": "Task 2: Add SourceNode to AuditEvent record", "status": "pending", "blockedBy": [2]}, + {"id": 4, "subject": "Task 3: Add SourceNode to SiteCallOperational + SiteCall entity", "status": "pending", "blockedBy": [2]}, + {"id": 5, "subject": "Task 4: Add SourceNode to Notification entity + NotificationSubmit", "status": "pending", "blockedBy": [2]}, + {"id": 6, "subject": "Task 5: Add source_node to proto + update DTO mappers", "status": "pending", "blockedBy": [3, 4]}, + {"id": 7, "subject": "Task 6: EF migration — SourceNode on AuditLog + IX_AuditLog_Node_Occurred", "status": "pending", "blockedBy": [3]}, + {"id": 8, "subject": "Task 7: EF migration — SourceNode on Notifications", "status": "pending", "blockedBy": [5]}, + {"id": 9, "subject": "Task 8: EF migration — SourceNode on SiteCalls", "status": "pending", "blockedBy": [4]}, + {"id": 10, "subject": "Task 9: Site SQLite AuditLog — add SourceNode (idempotent upgrade)", "status": "pending", "blockedBy": [3]}, + {"id": 11, "subject": "Task 10: Site SQLite OperationTracking — add SourceNode", "status": "pending", "blockedBy": [4]}, + {"id": 12, "subject": "Task 11: Stamp SourceNode at site SqliteAuditWriter", "status": "pending", "blockedBy": [2, 10]}, + {"id": 13, "subject": "Task 12: Stamp SourceNode at CentralAuditWriter + persist via repo", "status": "pending", "blockedBy": [2, 7]}, + {"id": 14, "subject": "Task 13: Carry SourceNode through Notifications S&F handoff", "status": "pending", "blockedBy": [2, 5, 8]}, + {"id": 15, "subject": "Task 14: Carry SourceNode through cached-call telemetry → SiteCalls", "status": "pending", "blockedBy": [2, 9, 11]}, + {"id": 16, "subject": "Task 15: UI — Node column + filter on AuditLog grid", "status": "pending", "blockedBy": [7]}, + {"id": 17, "subject": "Task 16: UI — Node column + filter on Notifications grid", "status": "pending", "blockedBy": [7]}, + {"id": 18, "subject": "Task 17: UI — Node column + filter on SiteCalls grid", "status": "pending", "blockedBy": [7]}, + {"id": 19, "subject": "Task 18: Docker appsettings — NodeName on all 8 nodes", "status": "pending", "blockedBy": [2]}, + {"id": 20, "subject": "Task 19: Full build + targeted test sweep", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 19]}, + {"id": 21, "subject": "Task 20: Docker redeploy + smoke verify", "status": "pending", "blockedBy": [20]} + ], + "lastUpdated": "2026-05-23T00:00:00Z" +} diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index 7280374..8522e50 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -86,6 +86,7 @@ row per lifecycle event across all channels. | `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. | | `ParentExecutionId` | `uniqueidentifier` NULL | The `ExecutionId` of the execution that *spawned* this run — the cross-execution correlation pointer. Set on every row of an inbound-API-routed site script run (= the inbound request's `ExecutionId`); NULL for a top-level run (inbound, tag-change / timer-triggered, un-bridged). | | `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. | +| `SourceNode` | `varchar(64)` NULL | The cluster node on which the event was emitted — `node-a` / `node-b` for site rows (qualified by `SourceSiteId`), `central-a` / `central-b` for central-originated rows. Nullable so reconciled rows from a node that has since been retired don't block ingest. | | `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). | | `SourceScript` | `varchar(128)` NULL | Script name within the instance. | | `Actor` | `varchar(128)` NULL | Inbound API: API key name. Outbound: script identity. Central: system user. | @@ -104,6 +105,7 @@ row per lifecycle event across all channels. - `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans. - `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters. +- `IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)` — per-node filters ("everything `central-a` did in window X", or pinning a misbehaving site node). - `IX_AuditLog_CorrelationId (CorrelationId)` — drilldown from a single operation. - `IX_AuditLog_Execution (ExecutionId)` — drilldown to every action of one script execution / inbound request. - `IX_AuditLog_ParentExecution (ParentExecutionId)` — cross-execution drilldown: the downward leg of the execution-tree walk seeks on it (`ParentExecutionId = ancestor.ExecutionId`), and it backs the `parentExecutionId` filter. @@ -172,7 +174,9 @@ generalises to it with no schema change once that spawn point is threaded. A SQLite database file on each site node, alongside the Store-and-Forward buffer. Same schema as central minus `IngestedAtUtc` (irrelevant at the source), plus a `ForwardState` column with values `Pending | Forwarded | Reconciled` that -drives the telemetry loop and reconciliation pull. +drives the telemetry loop and reconciliation pull. `SourceNode` is stamped by the +writing node itself (`node-a` / `node-b`) at append time and travels with the row +through telemetry and reconciliation unchanged. **Site SQLite retention rule (hard invariant):** @@ -233,7 +237,9 @@ instead. The Notification Outbox dispatcher writes `Notification.NotifyDeliver` with `Status=Attempted` per delivery attempt and `Notification.NotifyDeliver` with `Status=Delivered`/`Parked`/`Discarded` on terminal status. Central direct-writes use the same insert-if-not-exists -semantics keyed on `EventId`. +semantics keyed on `EventId`. `SourceSiteId` is NULL on all central direct-write +rows; `SourceNode` is stamped to the local central node's role name +(`central-a` / `central-b`). ## Cached Operations — Combined Telemetry diff --git a/docs/requirements/Component-NotificationOutbox.md b/docs/requirements/Component-NotificationOutbox.md index e7cfb37..39f2713 100644 --- a/docs/requirements/Component-NotificationOutbox.md +++ b/docs/requirements/Component-NotificationOutbox.md @@ -63,6 +63,7 @@ The table is type-agnostic so it can record any notification type the system sup | `LastError` | Detail of the most recent failure. | | `ResolvedTargets` | Who the notification actually went to — snapshotted by central at delivery time, for audit. | | `SourceSiteId`, `SourceInstanceId`, `SourceScript` | Provenance. | +| `SourceNode` | The cluster node on which the notification was enqueued — `node-a` / `node-b` for site-originated rows (qualified by `SourceSiteId`). Nullable. Carried verbatim from the site through the S&F handoff. | | `SiteEnqueuedAt` | When the script called `Send()` (carried from the site). | | `CreatedAt` | When central ingested the row. | | `LastAttemptAt`, `NextAttemptAt`, `DeliveredAt` | Delivery timestamps. | diff --git a/docs/requirements/Component-SiteCallAudit.md b/docs/requirements/Component-SiteCallAudit.md index d020d43..06980f5 100644 --- a/docs/requirements/Component-SiteCallAudit.md +++ b/docs/requirements/Component-SiteCallAudit.md @@ -36,6 +36,10 @@ Lives in the central MS SQL configuration database — a sibling of the - **TrackedOperationId** — GUID, primary key. Generated site-side at call time. - **SourceSite** — site that issued the call. +- **SourceNode** — the cluster node on which the call was issued (`node-a` / + `node-b`, qualified by `SourceSite`). Nullable. Stamped site-side at submit + time and carried verbatim through the combined `CachedCallTelemetry` packet, + reconciliation pulls, and the central upsert. - **Kind** — `TrackedOperationKind` enum: `ExternalCall` or `DatabaseWrite`. - **TargetSummary** — external system + method name for an `ExternalCall`; for a `DatabaseWrite`, just the database connection name — intentionally not the SQL