Files
scadalink-design/docs/plans/2026-05-21-audit-parent-executionid.md

221 lines
23 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 Log ParentExecutionId — 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 `ParentExecutionId` column to the Audit Log so an execution spawned by another (first cut: an inbound API request that routes to a site script) records a pointer back to its spawner, making audit call chains traceable across the execution boundary.
**Architecture:** Additive nullable `ParentExecutionId` (`Guid`) on the audit row (Commons `AuditEvent`, central MS SQL `AuditLog`, site SQLite `auditlog.db`, gRPC `AuditEventDto`). The inbound API request's `ExecutionId` is minted once at the HTTP entry, threaded onto `RouteToCallRequest``ScriptCallRequest` → the routed script's `ScriptRuntimeContext` as a new `parentExecutionId`; the routed script still mints its own fresh `ExecutionId`. Every emitter stamps `ParentExecutionId` as a sibling to `ExecutionId` — through the S&F buffer for retry-loop cached rows and through `NotificationSubmit``Notifications.OriginParentExecutionId` for central `NotifyDeliver` rows. A recursive repository query plus a Central UI tree view reconstruct the chain. Validated design: `docs/plans/2026-05-21-audit-parent-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-parent-executionid` (already created) — never commit to `main`. TDD — write the failing test first, then the minimal implementation. Edit in place; never touch `infra/*` or `alog.md`; `docker/*` only if a task says so (none do). Stage with explicit `git add <path>` — never `git add .` / `commit -am`. Full solution stays green (`dotnet build ScadaLink.slnx` 0 warnings — `TreatWarningsAsErrors` is on; `dotnet test ScadaLink.slnx` for touched suites). Additive contract evolution only. Do not push.
---
## Task 0: Prep — verify branch + baseline
**Files:** none.
**Steps:** confirm `git branch --show-current` is `feature/audit-parent-executionid`; run `dotnet build ScadaLink.slnx` and confirm it succeeds with 0 warnings.
**Acceptance:** on the branch, solution builds clean.
---
## Task 1: Foundation — `AuditEvent.ParentExecutionId`, central `AuditLog` column, repository query
**Files:**
- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — add `Guid? ParentExecutionId` (sibling to `ExecutionId`, same XML-doc style).
- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — add `Guid? ParentExecutionId` single-value filter dimension (mirror `ExecutionId`).
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — map the column; add index `IX_AuditLog_ParentExecution (ParentExecutionId)`.
- Create: EF migration under `src/ScadaLink.ConfigurationDatabase/Migrations/``AddAuditLogParentExecutionId``ParentExecutionId uniqueidentifier NULL` + the index. Mirror `20260521184044_AddAuditLogExecutionId` exactly (partition-aligned index, metadata-only `ALTER`).
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs``QueryAsync` translates `filter.ParentExecutionId` to `e.ParentExecutionId == value` (mirror the `ExecutionId` clause). Keyset paging untouched.
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs``QueryAsync_FilterByParentExecutionId`; migration smoke if the suite has that pattern.
**Approach:** purely additive; `ParentExecutionId` is `Guid?` everywhere. Generate the migration the same way `AddAuditLogExecutionId` was produced (match the repo's migration workflow).
**Commit:** `feat(auditlog): ParentExecutionId column on AuditEvent + central AuditLog`
---
## Task 2: Foundation — site SQLite + gRPC DTO
**Files:**
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `ParentExecutionId TEXT NULL` to the `auditlog.db` `AuditLog` table; the insert command binds it; `MapRow` reads it back. **Add the column via the idempotent `ALTER TABLE ... ADD COLUMN`-if-missing upgrade path** (the same path commit `5198b11` introduced for `ExecutionId` — locate it and extend it; do NOT rely on `CREATE TABLE IF NOT EXISTS` for the new column on an existing site DB).
- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` — add `string parent_execution_id` to `AuditEventDto` (next free field number; additive). Rebuild regenerates the C# stubs.
- Modify: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs``ToDto`/`FromDto` map `ParentExecutionId``parent_execution_id` (Guid ↔ string; empty string ↔ null, mirroring the existing `ExecutionId` handling).
- Test: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` — column present, round-trips, and the `ALTER`-if-missing path adds it to a pre-existing DB lacking the column; `tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs``ParentExecutionId` round-trip incl. null.
**Commit:** `feat(auditlog): ParentExecutionId on site SQLite schema + gRPC AuditEventDto`
---
## Task 3: Inbound request id minting + `RouteToCallRequest.ParentExecutionId`
**What:** The id propagated as `ParentExecutionId` is the inbound API request's `ExecutionId`. Today `AuditWriteMiddleware` mints it late, only for the inbound audit row. Mint it once early and stash it so `InboundScriptExecutor` can carry it onto the routing RPC.
**Files:**
- Modify: `src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs` — add `Guid? ParentExecutionId` to the `RouteToCallRequest` record (additive — append as the last positional param with a default, or make it a settable init property; match how the codebase evolves records).
- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs` (+ `AuditWriteMiddlewareExtensions.cs` if the pipeline order needs it) — mint the request `ExecutionId` (`Guid.NewGuid()`) at the start of the request, stash it on `HttpContext.Items` under a well-known key (add a small constant, e.g. `InboundExecutionContext.HttpItemKey`); `EmitInboundAudit` reads that same id for the inbound row's `ExecutionId` instead of minting its own.
- Modify: `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs` — read the stashed inbound `ExecutionId` from `HttpContext.Items` (or accept it as a parameter from the endpoint that has the `HttpContext`).
- Modify: `src/ScadaLink.InboundAPI/RouteHelper.cs` (~line where `RouteToCallRequest` is built) — set `ParentExecutionId` on the `RouteToCallRequest` from the inbound `ExecutionId`. Leave `RouteHelper`'s own per-op `CorrelationId` GUID alone — separate concern.
- Modify: `src/ScadaLink.InboundAPI/EndpointExtensions.cs` if the inbound `ExecutionId` must be plumbed from the endpoint into `InboundScriptExecutor`.
- Test: `tests/ScadaLink.InboundAPI.Tests/``AuditWriteMiddlewareTests` (inbound row uses the early-minted id; distinct per request); a `RouteHelper`/`InboundScriptExecutor` test that a routed `RouteToCallRequest` carries `ParentExecutionId` = the inbound request's `ExecutionId`.
**Approach:** the inbound request's own audit row stays top-level — `ParentExecutionId` is NOT set on it (it remains `NULL`). Only the spawn id flows outward on `RouteToCallRequest`. If the early mint cannot cleanly be shared between middleware and executor, STOP and report before guessing the pipeline shape.
**Commit:** `feat(inboundapi): mint inbound ExecutionId early, carry it as RouteToCallRequest.ParentExecutionId`
---
## Task 4: Thread `ParentExecutionId` into the routed script's `ScriptRuntimeContext`
**What:** Carry the `RouteToCallRequest.ParentExecutionId` site-side down to the routed script's `ScriptRuntimeContext`. The routed script still generates its own fresh `ExecutionId`.
**Files:**
- Modify: `src/ScadaLink.Commons/Messages/ScriptExecution/ScriptCallRequest.cs` — add `Guid? ParentExecutionId` (additive). This is the message `RouteInboundApiCall` builds.
- Modify: `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs` `RouteInboundApiCall` (~line 734) — set `ParentExecutionId = request.ParentExecutionId` on the `ScriptCallRequest` it builds from the `RouteToCallRequest`.
- Modify: `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs` `HandleScriptCallRequest` (~line 319) — forward `request.ParentExecutionId` onward.
- Modify: `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs` `HandleScriptCallRequest` (~line 175) — pass `ParentExecutionId` into the `ScriptExecutionActor` it spawns.
- Modify: `src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs` — add an optional `Guid? parentExecutionId = null` ctor param; thread it through `ExecuteScript` into `new ScriptRuntimeContext(...)`.
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs` — add an optional `Guid? parentExecutionId = null` ctor param (sibling to the existing `executionId` param ~line 144); store `_parentExecutionId`; XML-doc it. Thread it to the helper sub-context types alongside `_executionId` (the inner `ExternalSystem`/`Database`/`Notify` helper structs at ~lines 386, 406, 1003 carry `_executionId` — give them `_parentExecutionId` too).
- Test: `tests/ScadaLink.SiteRuntime.Tests/` — a test that a `ScriptCallRequest` carrying `ParentExecutionId` produces a `ScriptRuntimeContext` whose `_parentExecutionId` is that value AND whose `ExecutionId` is freshly generated (distinct); a `RouteToCallRequest``ScriptCallRequest` mapping test on `DeploymentManagerActor`.
**Note for implementer:** this task only threads the value — no emitter stamps it yet (Task 5). A normal (tag/timer) script run passes no `ParentExecutionId`, so `_parentExecutionId` stays `null`. Verify the helper sub-context plumbing matches exactly how `_executionId` is already threaded; if the ctor param ordering is awkward, mirror the `executionId` decision documented at `ScriptRuntimeContext.cs:396`.
**Commit:** `feat(siteruntime): thread ParentExecutionId into the routed script's ScriptRuntimeContext`
---
## Task 5: Site script-side emitters stamp `ParentExecutionId`
**What:** Every audit row a `ScriptRuntimeContext` emits gets `ParentExecutionId = _parentExecutionId` alongside `ExecutionId = _executionId`.
**Files:**
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs`:
- Sync `ApiCall` (`BuildCallAuditEvent` / the sync emission ~line 932): set `ParentExecutionId = _parentExecutionId`.
- Cached script-side rows (`CachedSubmit`, immediate `ApiCallCached`/`CachedResolve` ~lines 582, 693, 759): set `ParentExecutionId = _parentExecutionId`.
- `NotifySend` emission: set `ParentExecutionId = _parentExecutionId`.
- Modify: `src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs` + `AuditingDbCommand.cs` — thread `_parentExecutionId` (sibling to the audit `_executionId` already threaded); sync `DbWrite` and cached DB-write rows set `ParentExecutionId = _parentExecutionId`.
- Test: extend `tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs`, `DatabaseSyncEmissionTests.cs`, `ExternalSystemCachedCallEmissionTests.cs`, `DatabaseCachedWriteEmissionTests.cs`, `NotifySendAuditEmissionTests.cs`, `ExecutionCorrelationContextTests.cs` — assert `ParentExecutionId` is the context's `_parentExecutionId` on every emitted row; assert it is `null` when the context was constructed without one.
**Commit:** `feat(auditlog): site script-side emitters stamp ParentExecutionId`
---
## Task 6: Cached S&F retry-loop rows carry `ParentExecutionId`
**What:** Thread `ParentExecutionId` through the store-and-forward buffer so the retry-loop cached audit rows (`CachedCallLifecycleBridge`) carry it — a sibling to the `ExecutionId` the `ExecutionId` rollout already threaded through this exact path.
**Files:**
- Modify: the S&F buffered cached-call message / payload in `src/ScadaLink.StoreAndForward/` (`StoreAndForwardService.cs` and the buffered message type — find where `ExecutionId` was added in the `ExecutionId` rollout's Task 4) — carry `ParentExecutionId` alongside.
- Modify: `CachedCallAttemptContext` (in `src/ScadaLink.StoreAndForward/` / referenced by `src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs`) — add a `ParentExecutionId` field beside `ExecutionId`.
- Modify: `src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs` `BuildPacket` — set `ParentExecutionId` from the context, beside the existing `ExecutionId`.
- Modify: the enqueue path (`ExternalSystem.CachedCall` / `Database.CachedWrite` in `ScriptRuntimeContext.cs` ~line 520, where `executionId: _executionId` is already passed into the buffered message) — also write `_parentExecutionId` into the buffered message.
- Test: `tests/ScadaLink.AuditLog.Tests/` cached-telemetry tests + `tests/ScadaLink.StoreAndForward.Tests/` — retry-loop rows carry the originating `ParentExecutionId` (incl. `null` for a non-routed run).
**Note for implementer:** the threading boundary is already open from the `ExecutionId` rollout — this is one more field at each existing carry point, not a new boundary. If the buffered message cannot cleanly carry it, STOP and report.
**Commit:** `feat(auditlog): thread ParentExecutionId through S&F for retry-loop cached rows`
---
## Task 7: Central `NotifyDeliver` rows carry `ParentExecutionId`
**Files:**
- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` — add `Guid? OriginParentExecutionId` (sibling to `OriginExecutionId`).
- Modify: `src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs``NotificationSubmit` carries `Guid? OriginParentExecutionId` (additive).
- Modify: `src/ScadaLink.ConfigurationDatabase/` — EF config for `Notifications` + a new migration `AddNotificationOriginParentExecutionId` (`Notifications.OriginParentExecutionId uniqueidentifier NULL`). Mirror `20260521193048_AddNotificationOriginExecutionId`.
- Modify: the site `NotifySend` forward path — the routed run's `_parentExecutionId` (on the `NotifySend` audit row from Task 5) also rides on the `NotificationSubmit` (set it where the submit is built — `ScriptRuntimeContext` `Notify.Send` / the S&F notification forwarder, beside `OriginExecutionId`).
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` — persist `OriginParentExecutionId` on insert; `BuildNotifyDeliverEvent` sets `ParentExecutionId = notification.OriginParentExecutionId`.
- Test: `tests/ScadaLink.NotificationOutbox.Tests/``NotifyDeliver` rows echo `OriginParentExecutionId`; `tests/ScadaLink.Commons.Tests/` contract shape.
**Commit:** `feat(auditlog): NotifyDeliver rows carry the originating ParentExecutionId`
---
## Task 8: Repository — `GetExecutionTreeAsync`
**What:** A repository method that, given any `ExecutionId`, returns the whole execution chain rooted at the topmost ancestor — for the Central UI tree view.
**Files:**
- Create: `src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs` — a record: `ExecutionId`, `ParentExecutionId`, `RowCount`, channels present, statuses present, `SourceSiteId`, `SourceInstanceId`, `FirstOccurredAtUtc`, `LastOccurredAtUtc`.
- Modify: `src/ScadaLink.Commons/Interfaces/` — the Audit Log repository interface gains `Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(Guid executionId, CancellationToken ct)`.
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — implement it:
1. **Walk up** to the root — iterative `SELECT TOP 1 ParentExecutionId FROM AuditLog WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL` until none; the last `ExecutionId` with no parent is the root. Cap the loop (e.g. 32) against corrupt data.
2. **Walk down** — a recursive CTE seeded at the root, joining `child.ParentExecutionId = parent.ExecutionId`; `OPTION (MAXRECURSION 32)`. Project each distinct `ExecutionId` with the summary aggregates (`GROUP BY`).
Use `FromSqlInterpolated`/raw SQL for the recursive CTE (EF Core cannot express it in LINQ); keep the SQL append-only-safe (SELECT only).
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs``GetExecutionTree_MultiLevelChain` (3-level chain returns all nodes rooted at the ancestor regardless of the entry node); `GetExecutionTree_StubParentNode` (a `ParentExecutionId` referencing an execution with no rows of its own yields a node with `RowCount = 0` / is surfaced as referenced); `GetExecutionTree_RespectsMaxRecursion`.
**Note for implementer:** chains are shallow (12 levels typical). The `ParentExecutionId` graph is acyclic by construction; `MAXRECURSION` is a guard, not a routine limit. A purged parent simply ends the upward walk.
**Commit:** `feat(auditlog): GetExecutionTreeAsync recursive execution-chain query`
---
## Task 9: Central UI — `ParentExecutionId` column, filter, parent drill-in
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — add `ParentExecutionId` to the column set (short form / monospace, like `ExecutionId`); it participates in the existing resize/reorder + `ColumnOrder`.
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (+ `.razor.cs`) + `AuditQueryModel.cs` — a `ParentExecutionId` paste text-filter; `ToFilter` maps it to `AuditLogQueryFilter.ParentExecutionId`.
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs``ApplyQueryStringFilters` accepts `?parentExecutionId=<guid>`; `BuildExportUrl` emits it.
- Add a "View parent execution" row/drilldown action (in `AuditDrilldownDrawer` and/or a grid row action) linking `/audit/log?executionId=<ParentExecutionId>`, shown only when the row has a non-null `ParentExecutionId`. Mirror the existing `?executionId=` drill-in.
- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (column renders, filter maps, query-param parsed, drill-in hidden when `ParentExecutionId` null); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (parent drill-in filters the grid).
Use the `frontend-design` skill for the column/filter/drill-in styling. Custom Blazor + Bootstrap only — no component frameworks.
**Commit:** `feat(centralui): ParentExecutionId column, filter and parent drill-in on the Audit Log page`
---
## Task 10: Central UI — execution-chain tree view
**What:** A page that renders the full execution chain (rooted at the topmost ancestor) as an expandable tree, reached via a "View execution chain" drill-in.
**Files:**
- Create: `src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor` (+ `.razor.cs`) — route `/audit/execution-tree`, accepts `?executionId=<guid>`; calls `GetExecutionTreeAsync` via the same data path the Audit Log page uses for its grid (mirror that — repository service in-process, or a ManagementService endpoint if the grid goes through one; if the latter, add the endpoint in Task 11).
- Create: `src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor` (+ `.razor.cs` + `.razor.css`) — a custom recursive Blazor tree component: assembles the tree from the flat `ExecutionTreeNode` list, renders expandable nodes each showing the execution summary (id short form, row count, channels/statuses, site/instance, time span); a node referenced as a parent but with `RowCount = 0` renders as a stub ("execution with no audited actions"); clicking a node navigates to `/audit/log?executionId=<node>`.
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor` (+ `.razor.cs`) and/or `AuditResultsGrid` — add a "View execution chain" action linking `/audit/execution-tree?executionId=<ExecutionId of the row>`.
- Modify: the Central UI `Audit` nav group if the page should be linkable (decide — it is primarily a drill-in target; a nav entry is optional).
- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (tree assembled correctly from a flat list incl. multi-level + stub node; node click navigates); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (drill-in → tree renders → node click filters the Audit Log grid).
Use the `frontend-design` skill for the tree component. Clean, corporate, internal-use aesthetic; custom component, no frameworks.
**Commit:** `feat(centralui): execution-chain tree view on the Audit Log page`
---
## Task 11: CLI + ManagementService — `ParentExecutionId` filter
**Files:**
- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` + `AuditQueryHelpers.cs``audit query --parent-execution-id <guid>`; `AuditQueryArgs` + `BuildQueryString` emit `parentExecutionId`.
- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` `ParseFilter` — parse `parentExecutionId` query param into `AuditLogQueryFilter.ParentExecutionId` (lax-parse — unparseable dropped).
- Modify: `src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs` `ParseFilter` — same.
- If Task 10's tree page goes through ManagementService rather than the repository in-process: add `GET /api/audit/execution-tree?executionId=<guid>` to `AuditEndpoints.cs` returning the `ExecutionTreeNode` list. Otherwise skip this bullet. No CLI `audit tree` command in the first cut.
- Test: `tests/ScadaLink.CLI.Tests/`, `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`.
**Commit:** `feat(audit): ParentExecutionId filter in the CLI and ManagementService`
---
## Task 12: End-to-end integration test + docs
**Files:**
- Create: `tests/ScadaLink.IntegrationTests/AuditLog/ParentExecutionIdCorrelationTests.cs` — boot a site+central pair; issue an inbound API request whose method script calls `Route.Call` into a site instance; the routed site script does a sync `ExternalSystem.Call`, a cached call, and a `Notify.Send`. Assert: every audit row the routed run produced (site + central, sync + cached lifecycle + `NotifySend`/`NotifyDeliver`) carries `ParentExecutionId` = the inbound request's `ExecutionId`; each routed-run row has its own distinct `ExecutionId`; the inbound `InboundRequest` row has `ParentExecutionId = NULL`. Assert `GetExecutionTreeAsync` returns both executions in one chain.
- Modify: `docs/requirements/Component-AuditLog.md` — add `ParentExecutionId` to the `AuditLog` schema table and the index list (`IX_AuditLog_ParentExecution`); extend the `ExecutionId vs CorrelationId` section with a paragraph on `ParentExecutionId` (cross-execution correlation; inbound→routed bridge; immediate-spawner tree; tag cascade deferred). (Do NOT modify `alog.md`.)
- Modify: `CLAUDE.md` — under the Centralized Audit Log decisions, one line noting `ParentExecutionId` as the cross-execution spawn pointer (inbound→routed-site-script bridge; tag cascade deferred).
- Modify: component #23 summary in `CLAUDE.md`'s Current Component List if it enumerates correlation columns (keep it in sync).
**Commit:** `test(auditlog): end-to-end ParentExecutionId correlation + docs`
---
## Final review
Dispatch a final cross-cutting review of the whole branch; full `dotnet build ScadaLink.slnx` (0 warnings) + `dotnet test ScadaLink.slnx`; hand back to the user for the push/merge/redeploy decision (do not push).
## Dependency summary
0 blocks all. 1 ← 0. 2 ← 1. 3 ← 0. 4 ← 3. 5 ← 4, 2. 6 ← 5. 7 ← 5, 1. 8 ← 1. 9 ← 1. 10 ← 8, 9. 11 ← 1. 12 ← 5, 6, 7, 10, 11.
Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → final review.