23 KiB
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— addGuid? ParentExecutionId(sibling toExecutionId, same XML-doc style). - Modify:
src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs— addGuid? ParentExecutionIdsingle-value filter dimension (mirrorExecutionId). - Modify:
src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs— map the column; add indexIX_AuditLog_ParentExecution (ParentExecutionId). - Create: EF migration under
src/ScadaLink.ConfigurationDatabase/Migrations/—AddAuditLogParentExecutionId—ParentExecutionId uniqueidentifier NULL+ the index. Mirror20260521184044_AddAuditLogExecutionIdexactly (partition-aligned index, metadata-onlyALTER). - Modify:
src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs—QueryAsynctranslatesfilter.ParentExecutionIdtoe.ParentExecutionId == value(mirror theExecutionIdclause). 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— addParentExecutionId TEXT NULLto theauditlog.dbAuditLogtable; the insert command binds it;MapRowreads it back. Add the column via the idempotentALTER TABLE ... ADD COLUMN-if-missing upgrade path (the same path commit5198b11introduced forExecutionId— locate it and extend it; do NOT rely onCREATE TABLE IF NOT EXISTSfor the new column on an existing site DB). - Modify:
src/ScadaLink.Communication/Protos/sitestream.proto— addstring parent_execution_idtoAuditEventDto(next free field number; additive). Rebuild regenerates the C# stubs. - Modify:
src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs—ToDto/FromDtomapParentExecutionId↔parent_execution_id(Guid ↔ string; empty string ↔ null, mirroring the existingExecutionIdhandling). - Test:
tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs— column present, round-trips, and theALTER-if-missing path adds it to a pre-existing DB lacking the column;tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs—ParentExecutionIdround-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— addGuid? ParentExecutionIdto theRouteToCallRequestrecord (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.csif the pipeline order needs it) — mint the requestExecutionId(Guid.NewGuid()) at the start of the request, stash it onHttpContext.Itemsunder a well-known key (add a small constant, e.g.InboundExecutionContext.HttpItemKey);EmitInboundAuditreads that same id for the inbound row'sExecutionIdinstead of minting its own. - Modify:
src/ScadaLink.InboundAPI/InboundScriptExecutor.cs— read the stashed inboundExecutionIdfromHttpContext.Items(or accept it as a parameter from the endpoint that has theHttpContext). - Modify:
src/ScadaLink.InboundAPI/RouteHelper.cs(~line whereRouteToCallRequestis built) — setParentExecutionIdon theRouteToCallRequestfrom the inboundExecutionId. LeaveRouteHelper's own per-opCorrelationIdGUID alone — separate concern. - Modify:
src/ScadaLink.InboundAPI/EndpointExtensions.csif the inboundExecutionIdmust be plumbed from the endpoint intoInboundScriptExecutor. - Test:
tests/ScadaLink.InboundAPI.Tests/—AuditWriteMiddlewareTests(inbound row uses the early-minted id; distinct per request); aRouteHelper/InboundScriptExecutortest that a routedRouteToCallRequestcarriesParentExecutionId= the inbound request'sExecutionId.
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— addGuid? ParentExecutionId(additive). This is the messageRouteInboundApiCallbuilds. - Modify:
src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.csRouteInboundApiCall(~line 734) — setParentExecutionId = request.ParentExecutionIdon theScriptCallRequestit builds from theRouteToCallRequest. - Modify:
src/ScadaLink.SiteRuntime/Actors/InstanceActor.csHandleScriptCallRequest(~line 319) — forwardrequest.ParentExecutionIdonward. - Modify:
src/ScadaLink.SiteRuntime/Actors/ScriptActor.csHandleScriptCallRequest(~line 175) — passParentExecutionIdinto theScriptExecutionActorit spawns. - Modify:
src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs— add an optionalGuid? parentExecutionId = nullctor param; thread it throughExecuteScriptintonew ScriptRuntimeContext(...). - Modify:
src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs— add an optionalGuid? parentExecutionId = nullctor param (sibling to the existingexecutionIdparam ~line 144); store_parentExecutionId; XML-doc it. Thread it to the helper sub-context types alongside_executionId(the innerExternalSystem/Database/Notifyhelper structs at ~lines 386, 406, 1003 carry_executionId— give them_parentExecutionIdtoo). - Test:
tests/ScadaLink.SiteRuntime.Tests/— a test that aScriptCallRequestcarryingParentExecutionIdproduces aScriptRuntimeContextwhose_parentExecutionIdis that value AND whoseExecutionIdis freshly generated (distinct); aRouteToCallRequest→ScriptCallRequestmapping test onDeploymentManagerActor.
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): setParentExecutionId = _parentExecutionId. - Cached script-side rows (
CachedSubmit, immediateApiCallCached/CachedResolve~lines 582, 693, 759): setParentExecutionId = _parentExecutionId. NotifySendemission: setParentExecutionId = _parentExecutionId.
- Sync
- Modify:
src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs+AuditingDbCommand.cs— thread_parentExecutionId(sibling to the audit_executionIdalready threaded); syncDbWriteand cached DB-write rows setParentExecutionId = _parentExecutionId. - Test: extend
tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs,DatabaseSyncEmissionTests.cs,ExternalSystemCachedCallEmissionTests.cs,DatabaseCachedWriteEmissionTests.cs,NotifySendAuditEmissionTests.cs,ExecutionCorrelationContextTests.cs— assertParentExecutionIdis the context's_parentExecutionIdon every emitted row; assert it isnullwhen 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.csand the buffered message type — find whereExecutionIdwas added in theExecutionIdrollout's Task 4) — carryParentExecutionIdalongside. - Modify:
CachedCallAttemptContext(insrc/ScadaLink.StoreAndForward// referenced bysrc/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs) — add aParentExecutionIdfield besideExecutionId. - Modify:
src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.csBuildPacket— setParentExecutionIdfrom the context, beside the existingExecutionId. - Modify: the enqueue path (
ExternalSystem.CachedCall/Database.CachedWriteinScriptRuntimeContext.cs~line 520, whereexecutionId: _executionIdis already passed into the buffered message) — also write_parentExecutionIdinto the buffered message. - Test:
tests/ScadaLink.AuditLog.Tests/cached-telemetry tests +tests/ScadaLink.StoreAndForward.Tests/— retry-loop rows carry the originatingParentExecutionId(incl.nullfor 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— addGuid? OriginParentExecutionId(sibling toOriginExecutionId). - Modify:
src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs—NotificationSubmitcarriesGuid? OriginParentExecutionId(additive). - Modify:
src/ScadaLink.ConfigurationDatabase/— EF config forNotifications+ a new migrationAddNotificationOriginParentExecutionId(Notifications.OriginParentExecutionId uniqueidentifier NULL). Mirror20260521193048_AddNotificationOriginExecutionId. - Modify: the site
NotifySendforward path — the routed run's_parentExecutionId(on theNotifySendaudit row from Task 5) also rides on theNotificationSubmit(set it where the submit is built —ScriptRuntimeContextNotify.Send/ the S&F notification forwarder, besideOriginExecutionId). - Modify:
src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs— persistOriginParentExecutionIdon insert;BuildNotifyDeliverEventsetsParentExecutionId = notification.OriginParentExecutionId. - Test:
tests/ScadaLink.NotificationOutbox.Tests/—NotifyDeliverrows echoOriginParentExecutionId;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 gainsTask<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(Guid executionId, CancellationToken ct). - Modify:
src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs— implement it:- Walk up to the root — iterative
SELECT TOP 1 ParentExecutionId FROM AuditLog WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULLuntil none; the lastExecutionIdwith no parent is the root. Cap the loop (e.g. 32) against corrupt data. - Walk down — a recursive CTE seeded at the root, joining
child.ParentExecutionId = parent.ExecutionId;OPTION (MAXRECURSION 32). Project each distinctExecutionIdwith the summary aggregates (GROUP BY). UseFromSqlInterpolated/raw SQL for the recursive CTE (EF Core cannot express it in LINQ); keep the SQL append-only-safe (SELECT only).
- Walk up to the root — iterative
- 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(aParentExecutionIdreferencing an execution with no rows of its own yields a node withRowCount = 0/ is surfaced as referenced);GetExecutionTree_RespectsMaxRecursion.
Note for implementer: chains are shallow (1–2 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) — addParentExecutionIdto the column set (short form / monospace, likeExecutionId); it participates in the existing resize/reorder +ColumnOrder. - Modify:
src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor(+.razor.cs) +AuditQueryModel.cs— aParentExecutionIdpaste text-filter;ToFiltermaps it toAuditLogQueryFilter.ParentExecutionId. - Modify:
src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs—ApplyQueryStringFiltersaccepts?parentExecutionId=<guid>;BuildExportUrlemits it. - Add a "View parent execution" row/drilldown action (in
AuditDrilldownDrawerand/or a grid row action) linking/audit/log?executionId=<ParentExecutionId>, shown only when the row has a non-nullParentExecutionId. Mirror the existing?executionId=drill-in. - Test:
tests/ScadaLink.CentralUI.Tests/bUnit (column renders, filter maps, query-param parsed, drill-in hidden whenParentExecutionIdnull);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>; callsGetExecutionTreeAsyncvia 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 flatExecutionTreeNodelist, 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 withRowCount = 0renders 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/orAuditResultsGrid— add a "View execution chain" action linking/audit/execution-tree?executionId=<ExecutionId of the row>. - Modify: the Central UI
Auditnav 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+BuildQueryStringemitparentExecutionId. - Modify:
src/ScadaLink.ManagementService/AuditEndpoints.csParseFilter— parseparentExecutionIdquery param intoAuditLogQueryFilter.ParentExecutionId(lax-parse — unparseable dropped). - Modify:
src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.csParseFilter— same. - If Task 10's tree page goes through ManagementService rather than the repository in-process: add
GET /api/audit/execution-tree?executionId=<guid>toAuditEndpoints.csreturning theExecutionTreeNodelist. Otherwise skip this bullet. No CLIaudit treecommand 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 callsRoute.Callinto a site instance; the routed site script does a syncExternalSystem.Call, a cached call, and aNotify.Send. Assert: every audit row the routed run produced (site + central, sync + cached lifecycle +NotifySend/NotifyDeliver) carriesParentExecutionId= the inbound request'sExecutionId; each routed-run row has its own distinctExecutionId; the inboundInboundRequestrow hasParentExecutionId = NULL. AssertGetExecutionTreeAsyncreturns both executions in one chain. - Modify:
docs/requirements/Component-AuditLog.md— addParentExecutionIdto theAuditLogschema table and the index list (IX_AuditLog_ParentExecution); extend theExecutionId vs CorrelationIdsection with a paragraph onParentExecutionId(cross-execution correlation; inbound→routed bridge; immediate-spawner tree; tag cascade deferred). (Do NOT modifyalog.md.) - Modify:
CLAUDE.md— under the Centralized Audit Log decisions, one line notingParentExecutionIdas 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.