Bundles A-F per cadence memory. Brainstorm decisions locked: infra/mssql test harness, single AuditEvent record (nullable IngestedAtUtc + ForwardState), PRIMARY filegroup, explicit index names.
21 KiB
Audit Log #23 — M1 Foundation Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task (bundled cadence per
feedback_subagent_cadence).
Goal: Land the AuditLog table (monthly-partitioned) plus DB roles in MS SQL, and add the Commons types + EF repo + new ScadaLink.AuditLog project skeleton that every later milestone depends on. After M1 the database is ready, the new project is wired into the solution, and dotnet build && dotnet test are both green.
Architecture: New AuditEvent record + audit enums + writer interfaces in Commons. New EF entity configuration + EF Core migration creating AuditLog table aligned to ps_AuditLog_Month partition scheme on OccurredAtUtc, plus scadalink_audit_writer and scadalink_audit_purger SQL roles. New IAuditLogRepository with append-only surface (no Update, no row-delete). New src/ScadaLink.AuditLog/ project skeleton + AuditLogOptions.
Tech Stack: .NET 10 / EF Core 10.0.7 / Microsoft.Data.SqlClient 6.0.2 / xUnit 2.9.3 / running infra/mssql container for integration tests.
Brainstorm decisions (locked):
- MSSQL test harness: integration tests hit the existing
infra/mssqlcontainer (requirecd infra && docker compose up -d). - AuditEvent shape: one record with nullable
IngestedAtUtc(set centrally) and nullableForwardState(set site-locally). - Filegroup: PRIMARY, hard-coded.
- Indexes: five named explicitly via
.HasDatabaseName("IX_AuditLog_…").
Pre-existing reality:
Entities/Audit/AuditLogEntry.cs(config-audit, 9 cols) coexists with newAuditEvent— no rename, no removal.IAuditService(config-audit) is distinct from newIAuditWriter/ICentralAuditWriter.tests/ScadaLink.IntegrationTests/uses EF in-memory — NOT usable for partition/role tests.- Roadmap M1-T10 (project skeleton) must run before M1-T9 (options class). Swapped in this plan.
Bundles (cadence-aligned)
Tasks 1–11 from the roadmap are grouped into 6 bundles. Each bundle = one implementer dispatch + one combined spec+quality reviewer. The final cross-bundle reviewer runs over the whole branch.
- Bundle A — Commons types (roadmap T1+T2+T3+T4): enums, AuditEvent record + ForwardState enum, IAuditWriter / ICentralAuditWriter, telemetry message DTOs.
- Bundle B — EF entity mapping (T5): DbSet + IEntityTypeConfiguration + indexes.
- Bundle C — Migration with partitioning + DB roles (T6+T7 merged — one migration file).
- Bundle D — Repository (T8): IAuditLogRepository + EF implementation + DI registration.
- Bundle E — AuditLog project skeleton + options (T10 then T9): new
src/ScadaLink.AuditLog/project +AuditLogOptions. - Bundle F — Docs paper trail (T11): controller-direct edit; no subagent needed for a 1–3 line update.
Bundle A — Commons types
Task 1: Add audit enums to Commons
Files:
- Create:
src/ScadaLink.Commons/Types/Enums/AuditChannel.cs - Create:
src/ScadaLink.Commons/Types/Enums/AuditKind.cs - Create:
src/ScadaLink.Commons/Types/Enums/AuditStatus.cs - Create:
src/ScadaLink.Commons/Types/Enums/AuditForwardState.cs - Create:
tests/ScadaLink.Commons.Tests/Types/Enums/AuditEnumTests.cs
AuditChannel members (4): ApiOutbound, DbOutbound, Notification, ApiInbound.
AuditKind members (10, per alog.md §4): ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve.
AuditStatus members (8, per alog.md §4): Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped.
AuditForwardState members (3): Pending, Forwarded, Reconciled.
Steps:
- Failing tests assert each enum's exact member set via
Enum.GetValues(typeof(T)).Cast<T>().Select(x => x.ToString())against a string-array literal. - Run: fail (enums don't exist).
- Implement the four enums (no
[Flags]). - Run: pass.
- Commit:
feat(commons): add Audit{Channel,Kind,Status,ForwardState} enums for #23.
Task 2: Add AuditEvent record
Files:
- Create:
src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs—public sealed record AuditEventwith the 20 central columns per alog.md §4, plus nullableAuditForwardState? ForwardStateand nullableDateTime? IngestedAtUtc. - Create:
tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs.
Properties (in alog.md §4 order):
Guid EventId, DateTime OccurredAtUtc, DateTime? IngestedAtUtc, AuditChannel Channel, AuditKind Kind, Guid? CorrelationId, string? SourceSiteId, string? SourceInstanceId, string? SourceScript, string? Actor, string? Target, AuditStatus Status, int? HttpStatus, int? DurationMs, string? ErrorMessage, string? ErrorDetail, string? RequestSummary, string? ResponseSummary, bool PayloadTruncated, string? Extra, AuditForwardState? ForwardState.
Steps:
- Failing test constructs an
AuditEvent, asserts each property reads back as set, assertswithexpression produces a new instance with one field changed. - Run: fail.
- Implement record with all properties as
init-only. - Run: pass.
- Commit:
feat(commons): add AuditEvent record (#23).
Task 3: Add IAuditWriter and ICentralAuditWriter
Files:
- Create:
src/ScadaLink.Commons/Interfaces/Services/IAuditWriter.cs - Create:
src/ScadaLink.Commons/Interfaces/Services/ICentralAuditWriter.cs - Create:
tests/ScadaLink.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs
Both interfaces expose Task WriteAsync(AuditEvent evt, CancellationToken ct = default). XML doc comments name Audit Log #23 as the owner; IAuditWriter is the abstraction the boundary code calls, ICentralAuditWriter is the central-only flavor (used by direct-write paths in M2+).
Steps:
- Failing reflection test:
typeof(IAuditWriter).GetMethod("WriteAsync")returns a method whose parameters are(AuditEvent, CancellationToken)and return type isTask. Same forICentralAuditWriter. - Run: fail.
- Implement both interfaces with XML docs.
- Run: pass.
- Commit:
feat(commons): add IAuditWriter and ICentralAuditWriter (#23).
Task 4: Add audit telemetry + pull message DTOs
Files:
- Create:
src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs—public sealed record AuditTelemetryEnvelope(Guid EnvelopeId, string SourceSiteId, IReadOnlyList<AuditEvent> Events). - Create:
src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs—public sealed record PullAuditEventsRequest(string SourceSiteId, DateTime SinceUtc, int BatchSize). - Create:
src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs—public sealed record PullAuditEventsResponse(IReadOnlyList<AuditEvent> Events, bool MoreAvailable). - Create:
tests/ScadaLink.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs.
Steps:
- Failing test constructs envelope with 3 events and asserts immutability and enumerability.
- Failing test constructs
PullAuditEventsRequest+PullAuditEventsResponsewithMoreAvailable=true. - Run: fail.
- Implement records.
- Run: pass.
- Commit:
feat(commons): add audit telemetry + pull message DTOs (#23).
Bundle A acceptance: Commons project compiles. Four enum tests, AuditEvent test, two interface contract tests, two telemetry-message tests all green. No existing tests regress.
Bundle B — EF entity mapping
Task 5: Extend ScadaLinkDbContext + add IEntityTypeConfiguration
Files:
- Modify:
src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs— addpublic DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();in the existing// Auditsection, directly after the existingAuditLogEntriesDbSet. Do not remove or modifyAuditLogEntries. - Create:
src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs—IEntityTypeConfiguration<AuditEvent>mapping to tableAuditLog, columns per alog.md §4 (with max lengths), PK onEventId, enum columns stored asvarchar(32)viaHasConversion<string>().HasMaxLength(32). No partition function declared here — that goes in the migration's raw SQL.
Five indexes with explicit names:
-
IX_AuditLog_OccurredAtUtcon (OccurredAtUtcdesc) -
IX_AuditLog_Site_Occurredon (SourceSiteId,OccurredAtUtcdesc) -
IX_AuditLog_CorrelationIdon (CorrelationId) whereCorrelationId IS NOT NULL -
IX_AuditLog_Channel_Status_Occurredon (Channel,Status,OccurredAtUtcdesc) -
IX_AuditLog_Target_Occurredon (Target,OccurredAtUtcdesc) whereTarget IS NOT NULL -
Modify:
OnModelCreating— apply viamodelBuilder.ApplyConfiguration(new AuditLogEntityTypeConfiguration()). -
Create:
tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs— use aModelBuilderdirectly (no DbContext required) and assert:- mapped table name is
AuditLog, - PK is
EventId, - exactly 21 properties are mapped (20 + ForwardState; IngestedAtUtc is one of the 20 per spec; but ForwardState is the +1),
- the five indexes exist with the documented names.
- mapped table name is
Steps:
- Failing test: model asserts on table name + PK + property count.
- Implement config + apply in
OnModelCreating; add the DbSet. - Failing test: model asserts five named indexes.
- Add
HasIndex(...).HasDatabaseName(...)for each. - Run: pass.
- Commit:
feat(configdb): map AuditEvent to AuditLog table with PK and five named indexes (#23).
Bundle B acceptance: ConfigurationDatabase project compiles. Mapping test passes. No existing ConfigurationDatabase.Tests regress.
Bundle C — Migration with partitioning + DB roles
Task 6+7 (merged): Create migration with partition function/scheme/table + DB roles
Files:
- Generate:
src/ScadaLink.ConfigurationDatabase/Migrations/<yyyyMMddHHmmss>_AddAuditLogTable.csvia:dotnet ef migrations add AddAuditLogTable --project src/ScadaLink.ConfigurationDatabase \ --startup-project src/ScadaLink.Host --output-dir Migrations - Customize the migration's
Up():- Raw SQL: create partition function
pf_AuditLog_Month(RANGE RIGHT FOR VALUES with month-boundaries from2026-01-01through2027-12-01UTC), and partition schemeps_AuditLog_MonthALL TO ([PRIMARY]). - Drop EF's auto-generated
CREATE TABLEand replace with raw SQL that createsAuditLogONps_AuditLog_Month(OccurredAtUtc). (Or: let EF generate the table, thenALTER TABLE … ADD CONSTRAINT … PK … ON ps_AuditLog_Month(OccurredAtUtc)— whichever EF 10 supports cleanly.) - Create the five named indexes via
migrationBuilder.CreateIndex(...), partition-aligned onps_AuditLog_Month(OccurredAtUtc)where appropriate. - Raw SQL roles, idempotent (
IF NOT EXISTS … CREATE ROLE):scadalink_audit_writer: GRANT INSERT ON AuditLog; GRANT SELECT ON AuditLog. (No UPDATE, no DELETE.)scadalink_audit_purger: GRANT ALTER ON SCHEMA::dbo; GRANT SELECT ON AuditLog. (Enables ALTER PARTITION FUNCTION SWITCH and SWITCH PARTITION.)
- Raw SQL: create partition function
Down()drops indexes, table, scheme, function, then both roles.- Create:
tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs— uses a fixture connecting to the runninginfra/mssqlcontainer via the connection string ininfra/mssql/.env(or skips withSkip.Ifwhen the env varSCADALINK_MSSQL_TEST_CONNis unset, so CI without the container still passes).
Integration test assertions:
sys.partition_functionscontainspf_AuditLog_Month.sys.partition_schemescontainsps_AuditLog_Month.INFORMATION_SCHEMA.TABLEScontainsAuditLogaligned to the partition scheme.sys.indexescontains the five expected named indexes.sys.database_principalscontains both roles.- Smoke test: log in as a user mapped to
scadalink_audit_writer, attemptUPDATE AuditLog …, expectSqlExceptionwith permission error.
Steps:
- Generate the migration; let EF auto-fill the body.
- Failing integration test: assert partition function exists.
- Edit migration to add the partition function + scheme + table alignment.
- Re-run: pass.
- Failing integration test: assert five indexes exist.
- Add named indexes to migration.
- Failing integration test: assert both roles exist with documented grants.
- Add roles to migration.
- Failing integration test: smoke
UPDATE AuditLogas writer expects permission error. - Verify role grants exclude UPDATE.
- Run: pass.
- Commit:
feat(configdb): add AuditLog migration with monthly partitioning and DB roles (#23).
Notes for the implementer:
- Use
Microsoft.Data.SqlClientdirectly in the test fixture (not EF) to issue raw SQL for grant assertions. Skip.If(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SCADALINK_MSSQL_TEST_CONN")), "MSSQL not available")— keeps tests CI-safe.- Test database name:
ScadaLinkAuditMigrationTest_<guid>(created per fixture, dropped on dispose).
Bundle C acceptance: Migration applied to a fresh test DB on the infra/mssql container creates the partition function/scheme/table/indexes/roles. Smoke test confirms UPDATE is denied for the writer role. All migration tests pass when SCADALINK_MSSQL_TEST_CONN is set; skip cleanly when unset.
Bundle D — Repository
Task 8: IAuditLogRepository + EF implementation + DI
Files:
-
Create:
src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs— three methods:Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default);Task<IReadOnlyList<AuditEvent>> QueryAsync(AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default);Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default);
Plus two small DTOs in the same file (or co-located
Types/Audit/):AuditLogQueryFilterrecord: nullableAuditChannel?,AuditKind?,AuditStatus?,string? SourceSiteId,string? Target,string? Actor,Guid? CorrelationId,DateTime? FromUtc,DateTime? ToUtc.AuditLogPagingrecord:int PageSize,Guid? AfterEventId,DateTime? AfterOccurredAtUtc(keyset).
-
Create:
src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs— implements all three methods:InsertIfNotExistsAsyncuses raw SQLIF NOT EXISTS (SELECT 1 FROM AuditLog WHERE EventId = @id) INSERT INTO AuditLog …viaDbContext.Database.ExecuteSqlInterpolatedAsync(bypasses change tracker).QueryAsyncbuilds anIQueryable<AuditEvent>, applies filters, projects, paged by keyset on(OccurredAtUtc desc, EventId desc).SwitchOutPartitionAsyncbuilds a unique staging table name, runsCREATE TABLE … <staging>with identical schema and ON[PRIMARY], runsALTER TABLE AuditLog SWITCH PARTITION <n> TO <staging>, thenDROP TABLE <staging>. All inside a single transaction. Computes partition number frommonthBoundaryvia$partition.pf_AuditLog_Month(@boundary).
-
Modify:
src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs— addservices.AddScoped<IAuditLogRepository, AuditLogRepository>();afterINotificationOutboxRepositoryline. -
Create:
tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs— uses the same MSSQL fixture from Bundle C (skipped when env var unset) sinceInsertIfNotExistsAsyncuses raw SQL that won't run on EF in-memory.
Tests:
- Insert for fresh
EventIdwrites one row. - Calling
InsertIfNotExistsAsyncagain with the sameEventIdis a no-op (no exception, row count unchanged). QueryAsyncreturns rows in(OccurredAtUtc desc, EventId desc)order honoring all filter predicates.QueryAsyncwith non-nullAfterEventId/AfterOccurredAtUtckeysets correctly to the next page.SwitchOutPartitionAsyncfor an old boundary removes the rows belonging to that partition from the live table.
Steps:
- Failing test: insert + duplicate insert.
- Implement using raw SQL.
- Failing test: query order + filters.
- Implement.
- Failing test: keyset paging.
- Implement.
- Failing test: switch-out partition.
- Implement.
- Run all: pass.
- Commit:
feat(configdb): IAuditLogRepository + EF implementation, append-only with partition-switch purge (#23).
Bundle D acceptance: Repository tests green. DI smoke test from existing ConfigurationDatabase.Tests still passes.
Bundle E — AuditLog project skeleton + options
Task 10 (first): Scaffold src/ScadaLink.AuditLog/ project
Files:
- Create:
src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj— TargetFrameworknet10.0(matches solution), referencesScadaLink.Commons+ScadaLink.ConfigurationDatabase. - Create:
src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs—public static class ServiceCollectionExtensions { public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config) { … } }registeringAuditLogOptions(from Task 9) and forwarding toservices.AddScoped<IAuditLogRepository, AuditLogRepository>()(already registered by ConfigurationDatabase, so this is a no-op but documents the dependency). - Create:
tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csprojwith one smoke test. - Create:
tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs— smoke test:services.AddAuditLog(config); var p = services.BuildServiceProvider(); Assert.NotNull(p.GetService<IOptions<AuditLogOptions>>());. - Modify:
ScadaLink.slnx— add both projects.
Steps:
dotnet new classlib -n ScadaLink.AuditLog -o src/ScadaLink.AuditLog --framework net10.0(then delete the defaultClass1.cs).dotnet new xunit -n ScadaLink.AuditLog.Tests -o tests/ScadaLink.AuditLog.Tests --framework net10.0.- Add
<ProjectReference>to Commons + ConfigurationDatabase in the src csproj; add reference to ScadaLink.AuditLog in the test csproj. - Add both projects to
ScadaLink.slnx(inside the existing/src/and/tests/folders). - Add
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />to the src csproj (already inDirectory.Packages.props). - Create stub
ServiceCollectionExtensions.AddAuditLog(just registers options; writer impl comes in M2). - Commit:
feat(auditlog): scaffold ScadaLink.AuditLog project (#23).
Task 9: AuditLogOptions
Files:
- Create:
src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs— class with:int DefaultCapBytes(default 8192)int ErrorCapBytes(default 65536)List<string> HeaderRedactList(default:[ "Authorization", "X-Api-Key", "Cookie", "Set-Cookie" ])List<string> GlobalBodyRedactors(default: empty)Dictionary<string, PerTargetRedactionOverride> PerTargetOverrides(default empty)int RetentionDays(default 365; range [30, 3650])
- Create:
src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs— minimal:int? CapBytes,List<string>? AdditionalBodyRedactors. - Create:
src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs—IValidateOptions<AuditLogOptions>checkingDefaultCapBytes > 0,ErrorCapBytes >= DefaultCapBytes,RetentionDaysin[30, 3650]. - Modify:
src/ScadaLink.AuditLog/ServiceCollectionExtensions.AddAuditLogtoservices.AddOptions<AuditLogOptions>().Bind(config.GetSection("AuditLog")).ValidateOnStart(); services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>();. - Add:
tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs:- Bind valid section → values present.
- Bind invalid
RetentionDays = 0→ validator rejects. - Bind invalid
ErrorCapBytes < DefaultCapBytes→ validator rejects.
Steps:
- Failing test: valid bind round-trip.
- Implement options class.
- Failing test: invalid
RetentionDays. - Implement validator.
- Failing test: invalid
ErrorCapBytes. - Validator covers it.
- Run: pass.
- Commit:
feat(auditlog): add AuditLogOptions + validator (#23).
Bundle E acceptance: New src/ScadaLink.AuditLog/ project builds. Solution still builds. Smoke + options tests green. ScadaLink.slnx includes both new entries.
Bundle F — Docs paper trail (controller-direct)
Task 11: Register AuditLog project in Component-Host.md and confirm README
Files:
- Modify:
docs/requirements/Component-Host.md— listScadaLink.AuditLogin the central role's registration set. - Modify:
README.md— confirm row #23 link reflects the new project (no functional change unless missing).
This is a 1–3 line edit. Per the cadence memory, controller does it directly without a subagent.
Commit: docs(audit): register ScadaLink.AuditLog project in Host role (#23).
Final cross-bundle review
After all bundles ship:
- Dispatch a final code-reviewer subagent over the whole M1 branch.
- Acceptance gate (from goal prompt step E):
dotnet test ScadaLink.slnxgreen (full solution).- All M1 roadmap acceptance criteria met; each cited by name to the proving test.
- If green, merge to main
--no-ffwith summary message (step F). - Update M2–M8 sections of the roadmap with realities learned (step G), commit.
- Status paragraph (step H).
- Proceed to M2 (step I).