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 NotificationSubmit → Notifications.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— addGuid? ExecutionId. - Modify:
src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs— addGuid? ExecutionIdfilter dimension (single-value, likeCorrelationId). - Modify:
src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs— map the column; add indexIX_AuditLog_Execution (ExecutionId). - Create: a new EF migration under
src/ScadaLink.ConfigurationDatabase/Migrations/—AddAuditLogExecutionId—ExecutionId uniqueidentifier NULL+ the index. Additive nullable column (metadata-only ALTER, safe on the monthly-partitioned table). Mirror the existingAddNotificationsTablemigration style. - Modify:
src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs—QueryAsynctranslatesfilter.ExecutionIdtoe.ExecutionId == value(mirror theCorrelationIdclause). Keyset paging untouched. - Test:
tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs—QueryAsync_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— addExecutionId TEXT NULLto theauditlog.dbAuditLogtable DDL; the insert command binds it;MapRowreads it back. (Site SQLite is created fresh by the writer — an additive column in theCREATE TABLEis enough; if the writer has any migration/ALTER path, extend it.) - Modify:
src/ScadaLink.Communication/Protos/sitestream.proto— addstring execution_idtoAuditEventDto(next free field number; additive). Rebuild regenerates the C# stubs. - Modify:
src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs—ToDto/FromDtomapExecutionId↔execution_id(Guid ↔ string; empty string ↔ null, mirroring the existingCorrelationIdhandling). - 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 paramauditCorrelationId→executionId) for clarity; update XML docs. Thread it to the helpers as today. - Sync
ApiCall(BuildCallAuditEvent): setExecutionId = _executionId; setCorrelationId = null(revert — sync one-shot calls have no operation lifecycle). - Cached script-side rows (
CachedSubmit, immediateApiCallCached/CachedResolve): setExecutionId = _executionId;CorrelationIdstaystrackedId.Value. NotifySend(Notify.Sendemission): setExecutionId = _executionId;CorrelationIdstays theNotificationId.
- Rename the field
- Modify:
src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs+AuditingDbCommand.cs— thread_executionId(rename from the audit-correlation param); syncDbWriteevent setsExecutionId = _executionIdandCorrelationId = null. Cached DB write rows:ExecutionIdset,CorrelationIdstaystrackedId. - Test: extend
tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs,DatabaseSyncEmissionTests.cs,ExternalSystemCachedCallEmissionTests.cs,DatabaseCachedWriteEmissionTests.cs,NotifySendAuditEmissionTests.cs, andExecutionCorrelationContextTests.cs— assertExecutionIdis the context's id on every row; assert sync rows now haveCorrelationId == null; assert cached/notification rows keep theirCorrelationId.
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) insrc/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 anExecutionId(andSourceScript) field. - Modify:
src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.csBuildPacket— setExecutionIdfrom the context (andSourceScript, replacing theSourceScript = nullline). - Modify the enqueue path (
ExternalSystem.CachedCall/Database.CachedWriteinScriptRuntimeContext) 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 originatingExecutionId.
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— addGuid? OriginExecutionId. - Modify:
src/ScadaLink.Commons/Messages/Notification/—NotificationSubmitcarriesGuid? OriginExecutionId(additive). - Modify:
src/ScadaLink.ConfigurationDatabase/— EF config + a new migrationAddNotificationOriginExecutionId(Notifications.OriginExecutionId uniqueidentifier NULL). - Modify: the site
NotifySendforward path — the execution id (already on theNotifySendaudit row from Task 3) also rides on theNotificationSubmit(set it where the submit is built —ScriptRuntimeContextNotify.Send/ the S&F notification forwarder). - Modify:
src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs— persistOriginExecutionIdon insert;BuildNotifyDeliverEventsetsExecutionId = notification.OriginExecutionId. - Test:
tests/ScadaLink.NotificationOutbox.Tests/—NotifyDeliverrows echoOriginExecutionId;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.cs—EmitInboundAuditsetsExecutionIdto the request id (it already mints aGuid.NewGuid()for the inboundCorrelationIdper the 2026-05-21 change; reuse that one id forExecutionId— and reconsider whether the inbound row'sCorrelationIdshould now benullto keepCorrelationIdpurely 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-nullExecutionId; 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) — addExecutionIdto the column set (the grid already supports resize/reorder + aColumnOrder); render it (short form / monospace). - Modify:
src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor(+.razor.cs) +AuditQueryModel.cs— anExecutionIdpaste text-filter;ToFiltermaps it toAuditLogQueryFilter.ExecutionId. - Modify:
src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs—ApplyQueryStringFiltersaccepts?executionId=<guid>;BuildExportUrlemits 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.cs—audit query --execution-id <guid>;AuditQueryArgs+BuildQueryStringemitexecutionId. - Modify:
src/ScadaLink.ManagementService/AuditEndpoints.csParseFilter— parseexecutionIdquery param intoAuditLogQueryFilter.ExecutionId(lax-parse — unparseable dropped). - Modify:
src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.csParseFilter— 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 syncExternalSystem.Call, a cached call, and aNotify.Send; assert every resulting audit row (site + central) shares oneExecutionId. - Modify:
docs/requirements/Component-AuditLog.md— addExecutionIdto the schema table and a sentence on its meaning vsCorrelationId. (Do NOT modifyalog.md— it is the locked v1 spec.) - Modify:
CLAUDE.md— one line under the Centralized Audit Log decisions notingExecutionIdas 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.