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

23 KiB
Raw Blame History

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 RouteToCallRequestScriptCallRequest → 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 NotificationSubmitNotifications.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/AddAuditLogParentExecutionIdParentExecutionId uniqueidentifier NULL + the index. Mirror 20260521184044_AddAuditLogExecutionId exactly (partition-aligned index, metadata-only ALTER).
  • Modify: src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.csQueryAsync translates filter.ParentExecutionId to e.ParentExecutionId == value (mirror the ExecutionId clause). Keyset paging untouched.
  • Test: tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.csQueryAsync_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.csToDto/FromDto map ParentExecutionIdparent_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.csParentExecutionId 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 RouteToCallRequestScriptCallRequest 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.csNotificationSubmit 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.csGetExecutionTree_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.csApplyQueryStringFilters 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.csaudit 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.