Files
scadalink-design/docs/plans/2026-05-21-audit-executionid.md
2026-05-21 14:37:12 -04:00

13 KiB

Audit Log ExecutionId — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to execute this plan task-by-task (fresh implementer per task + spec review + code-quality review).

Goal: Add a dedicated ExecutionId column to the Audit Log — one universal correlation value, stamped on every audit row, identifying the originating script execution or inbound request.

Architecture: Additive nullable ExecutionId (Guid) on the audit row (Commons AuditEvent, central MS SQL AuditLog, site SQLite auditlog.db, gRPC AuditEventDto). Every emitter stamps it; the ScriptRuntimeContext per-execution id is the source for site script runs, threaded through the S&F buffer for retry-loop cached rows and through NotificationSubmitNotifications.OriginExecutionId for central NotifyDeliver rows. CorrelationId is left as the per-operation lifecycle id (and reverts to null for sync one-shot calls). Validated design: docs/plans/2026-05-21-audit-executionid-design.md.

Tech Stack: .NET 10, EF Core 10 (MS SQL + SQLite), Akka.NET, gRPC, Blazor Server + Bootstrap, System.CommandLine, xUnit + Akka.TestKit.Xunit2 + bUnit + NSubstitute/Moq, Playwright.

Ground rules (every task): branch is feature/audit-executionid (already created) — never commit to main. Edit in place; never touch infra/*; docker/* only if a task says so (none do). Stage with explicit git add <path> — never git add . / commit -am. TDD; full solution stays green (dotnet build ScadaLink.slnx 0 warnings — TreatWarningsAsErrors is on). Additive contract evolution. Do not push.


Task 0: Prep — verify branch + baseline

Files: none.

Steps: confirm git branch --show-current is feature/audit-executionid; dotnet build ScadaLink.slnx succeeds.

Acceptance: on the branch, solution builds clean.


Task 1: Foundation — AuditEvent.ExecutionId, central AuditLog column, repository query

Files:

  • Modify: src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs — add Guid? ExecutionId.
  • Modify: src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs — add Guid? ExecutionId filter dimension (single-value, like CorrelationId).
  • Modify: src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs — map the column; add index IX_AuditLog_Execution (ExecutionId).
  • Create: a new EF migration under src/ScadaLink.ConfigurationDatabase/Migrations/AddAuditLogExecutionIdExecutionId uniqueidentifier NULL + the index. Additive nullable column (metadata-only ALTER, safe on the monthly-partitioned table). Mirror the existing AddNotificationsTable migration style.
  • Modify: src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.csQueryAsync translates filter.ExecutionId to e.ExecutionId == value (mirror the CorrelationId clause). Keyset paging untouched.
  • Test: tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.csQueryAsync_FilterByExecutionId; migration smoke if the suite has that pattern.

Approach: purely additive. ExecutionId is Guid? everywhere. Generate the migration with dotnet ef migrations add against the ConfigurationDatabase project (or hand-write mirroring an existing one — match how the repo does migrations).

Commit: feat(auditlog): ExecutionId column on AuditEvent + central AuditLog


Task 2: Foundation — site SQLite + gRPC DTO

Files:

  • Modify: src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs — add ExecutionId TEXT NULL to the auditlog.db AuditLog table DDL; the insert command binds it; MapRow reads it back. (Site SQLite is created fresh by the writer — an additive column in the CREATE TABLE is enough; if the writer has any migration/ALTER path, extend it.)
  • Modify: src/ScadaLink.Communication/Protos/sitestream.proto — add string execution_id to AuditEventDto (next free field number; additive). Rebuild regenerates the C# stubs.
  • Modify: src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.csToDto/FromDto map ExecutionIdexecution_id (Guid ↔ string; empty string ↔ null, mirroring the existing CorrelationId handling).
  • Test: tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs (column present + round-trips); tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs (ExecutionId round-trip incl. null).

Commit: feat(auditlog): ExecutionId on site SQLite schema + gRPC AuditEventDto


Task 3: Site script-side emitters stamp ExecutionId

What: Every audit row a ScriptRuntimeContext emits gets ExecutionId = the context's per-execution id. Revert the interim "execution id in CorrelationId for sync rows" change so CorrelationId is purely per-operation again.

Files:

  • Modify: src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs:
    • Rename the field _auditCorrelationId_executionId (and the ctor param auditCorrelationIdexecutionId) for clarity; update XML docs. Thread it to the helpers as today.
    • Sync ApiCall (BuildCallAuditEvent): set ExecutionId = _executionId; set CorrelationId = null (revert — sync one-shot calls have no operation lifecycle).
    • Cached script-side rows (CachedSubmit, immediate ApiCallCached/CachedResolve): set ExecutionId = _executionId; CorrelationId stays trackedId.Value.
    • NotifySend (Notify.Send emission): set ExecutionId = _executionId; CorrelationId stays the NotificationId.
  • Modify: src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs + AuditingDbCommand.cs — thread _executionId (rename from the audit-correlation param); sync DbWrite event sets ExecutionId = _executionId and CorrelationId = null. Cached DB write rows: ExecutionId set, CorrelationId stays trackedId.
  • Test: extend tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs, DatabaseSyncEmissionTests.cs, ExternalSystemCachedCallEmissionTests.cs, DatabaseCachedWriteEmissionTests.cs, NotifySendAuditEmissionTests.cs, and ExecutionCorrelationContextTests.cs — assert ExecutionId is the context's id on every row; assert sync rows now have CorrelationId == null; assert cached/notification rows keep their CorrelationId.

Commit: feat(auditlog): site script-side emitters stamp ExecutionId


Task 4: Cached S&F retry-loop rows carry ExecutionId

What: Thread the execution id through the store-and-forward buffer so the retry-loop cached audit rows (CachedCallLifecycleBridge) carry ExecutionId. This same threading fixes the pre-existing SourceScript = null gap on those rows (identical boundary).

Files:

  • Modify: the S&F buffered cached-call message / StoreAndForwardMessage (or the cached-call payload) in src/ScadaLink.StoreAndForward/ — carry the originating execution id (and source script) alongside the call.
  • Modify: CachedCallAttemptContext (find it — src/ScadaLink.AuditLog/Site/Telemetry/ or StoreAndForward) — add an ExecutionId (and SourceScript) field.
  • Modify: src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs BuildPacket — set ExecutionId from the context (and SourceScript, replacing the SourceScript = null line).
  • Modify the enqueue path (ExternalSystem.CachedCall / Database.CachedWrite in ScriptRuntimeContext) so the execution id is written into the buffered message.
  • Test: tests/ScadaLink.AuditLog.Tests/ cached-telemetry tests + tests/ScadaLink.StoreAndForward.Tests/ — retry-loop rows carry the originating ExecutionId.

Note for implementer: this is the deepest task — the threading touches StoreAndForward. If the buffered message can't cleanly carry the id, STOP and report before guessing.

Commit: feat(auditlog): thread ExecutionId through S&F for retry-loop cached rows


Task 5: Central NotifyDeliver rows carry ExecutionId

Files:

  • Modify: src/ScadaLink.Commons/Entities/Notifications/Notification.cs — add Guid? OriginExecutionId.
  • Modify: src/ScadaLink.Commons/Messages/Notification/NotificationSubmit carries Guid? OriginExecutionId (additive).
  • Modify: src/ScadaLink.ConfigurationDatabase/ — EF config + a new migration AddNotificationOriginExecutionId (Notifications.OriginExecutionId uniqueidentifier NULL).
  • Modify: the site NotifySend forward path — the execution id (already on the NotifySend audit row from Task 3) also rides on the NotificationSubmit (set it where the submit is built — ScriptRuntimeContext Notify.Send / the S&F notification forwarder).
  • Modify: src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs — persist OriginExecutionId on insert; BuildNotifyDeliverEvent sets ExecutionId = notification.OriginExecutionId.
  • Test: tests/ScadaLink.NotificationOutbox.Tests/NotifyDeliver rows echo OriginExecutionId; tests/ScadaLink.Commons.Tests/ contract shape.

Commit: feat(auditlog): NotifyDeliver rows carry the originating ExecutionId


Task 6: Inbound rows carry ExecutionId

Files:

  • Modify: src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.csEmitInboundAudit sets ExecutionId to the request id (it already mints a Guid.NewGuid() for the inbound CorrelationId per the 2026-05-21 change; reuse that one id for ExecutionId — and reconsider whether the inbound row's CorrelationId should now be null to keep CorrelationId purely per-operation; align with the Task 3 decision: inbound is a one-shot from the audit row's perspective → CorrelationId = null, ExecutionId = <request id>).
  • Test: tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs — inbound row carries a non-null ExecutionId; distinct per request.

Commit: feat(auditlog): inbound audit rows carry ExecutionId


Task 7: Central UI — ExecutionId column, filter, drill-in

Files:

  • Modify: src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor (+ .razor.cs) — add ExecutionId to the column set (the grid already supports resize/reorder + a ColumnOrder); render it (short form / monospace).
  • Modify: src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor (+ .razor.cs) + AuditQueryModel.cs — an ExecutionId paste text-filter; ToFilter maps it to AuditLogQueryFilter.ExecutionId.
  • Modify: src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.csApplyQueryStringFilters accepts ?executionId=<guid>; BuildExportUrl emits it.
  • Add a "View this execution" drill-in — a row/drilldown action linking /audit/log?executionId=<guid>. Mirror the existing ?correlationId= drill-in.
  • Test: tests/ScadaLink.CentralUI.Tests/ bUnit (column renders, filter maps, query-param parsed); tests/ScadaLink.CentralUI.PlaywrightTests/Audit/ (drill-in filters the grid).

Use the frontend-design skill for the column/filter styling.

Commit: feat(centralui): ExecutionId column, filter and drill-in on the Audit Log page


Task 8: CLI + ManagementService — ExecutionId filter

Files:

  • Modify: src/ScadaLink.CLI/Commands/AuditCommands.cs + AuditQueryHelpers.csaudit query --execution-id <guid>; AuditQueryArgs + BuildQueryString emit executionId.
  • Modify: src/ScadaLink.ManagementService/AuditEndpoints.cs ParseFilter — parse executionId query param into AuditLogQueryFilter.ExecutionId (lax-parse — unparseable dropped).
  • Modify: src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs ParseFilter — same.
  • Test: tests/ScadaLink.CLI.Tests/, tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs.

Commit: feat(audit): ExecutionId filter in the CLI and ManagementService


Task 9: End-to-end integration test + docs

Files:

  • Create: tests/ScadaLink.IntegrationTests/AuditLog/ExecutionIdCorrelationTests.cs — boot a site+central pair; run a script that does a sync ExternalSystem.Call, a cached call, and a Notify.Send; assert every resulting audit row (site + central) shares one ExecutionId.
  • Modify: docs/requirements/Component-AuditLog.md — add ExecutionId to the schema table and a sentence on its meaning vs CorrelationId. (Do NOT modify alog.md — it is the locked v1 spec.)
  • Modify: CLAUDE.md — one line under the Centralized Audit Log decisions noting ExecutionId as the universal per-run correlation value.

Commit: test(auditlog): end-to-end ExecutionId correlation + docs


Final review

Dispatch a final cross-cutting review of the whole branch; full dotnet build + dotnet test ScadaLink.slnx; hand back to the user for the push/merge/redeploy decision (do not push).

Dependency summary

0 blocks all. 2 blockedBy 1. 3 blockedBy 2. 4 blockedBy 3. 5 blockedBy 2. 6 blockedBy 2. 7 blockedBy 1. 8 blockedBy 1. 9 blockedBy 3,4,5,6,7,8. Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → final review.