docs(plan): Audit Log ExecutionId implementation plan

This commit is contained in:
Joseph Doherty
2026-05-21 14:37:12 -04:00
parent 6ffa47f258
commit 4002f4197b
2 changed files with 171 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
# 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` — 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/``AddAuditLogExecutionId``ExecutionId 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.cs``QueryAsync` translates `filter.ExecutionId` to `e.ExecutionId == value` (mirror the `CorrelationId` clause). 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` — 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.cs``ToDto`/`FromDto` map `ExecutionId``execution_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 `auditCorrelationId``executionId`) 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.cs``EmitInboundAudit` 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.cs``ApplyQueryStringFilters` 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.cs``audit 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.

View File

@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-05-21-audit-executionid.md",
"tasks": [
{"id": 50, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
{"id": 51, "subject": "Task 1: Foundation — AuditEvent.ExecutionId + central AuditLog column + repo query", "status": "pending", "blockedBy": [50]},
{"id": 52, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [51]},
{"id": 53, "subject": "Task 3: Site script-side emitters stamp ExecutionId", "status": "pending", "blockedBy": [52]},
{"id": 54, "subject": "Task 4: Cached S&F retry-loop rows carry ExecutionId", "status": "pending", "blockedBy": [53]},
{"id": 55, "subject": "Task 5: Central NotifyDeliver rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
{"id": 56, "subject": "Task 6: Inbound audit rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
{"id": 57, "subject": "Task 7: Central UI — ExecutionId column, filter, drill-in", "status": "pending", "blockedBy": [51]},
{"id": 58, "subject": "Task 8: CLI + ManagementService — ExecutionId filter", "status": "pending", "blockedBy": [51]},
{"id": 59, "subject": "Task 9: End-to-end integration test + docs", "status": "pending", "blockedBy": [53, 54, 55, 56, 57, 58]}
],
"lastUpdated": "2026-05-21T00:00:00Z"
}