8 bundles (A: race-fix + tiebreaker, B: SQLite writer + ring fallback, C: gRPC proto + mapper, D: telemetry actor + ingest actor + gRPC handler, E: host wiring, F: ESG audit emission via ScriptRuntimeContext wrapper, G: SiteAuditWriteFailures health metric, H: component-level e2e test). Brainstorm decisions locked: provenance via ScriptRuntimeContext wrapper, push-primary telemetry, component-level e2e (no factory expansion), mirror SiteEventLogger Channel<T> pattern for SqliteAuditWriter.
23 KiB
Audit Log #23 — M2 Site Pipeline (sync-only) Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (bundled cadence per
feedback_subagent_cadence).
Goal: First end-to-end audit emission. A script-initiated ExternalSystem.Call() produces exactly one ApiOutbound/ApiCall row in the central AuditLog table via site SQLite hot-path + gRPC push telemetry + central ingest actor. Audit-write failures NEVER abort the script.
Architecture (decisions locked):
- Provenance: Wrap CallAsync in ScriptRuntimeContext — IExternalSystemClient.CallAsync signature unchanged; ScriptRuntimeContext.ExternalSystem.Call captures instance/script/site and emits the AuditEvent via IAuditWriter.
- Direction: Push primary — SiteAuditTelemetryActor batches Pending rows and pushes via a new
IngestAuditEventsunary gRPC RPC onsitestream.proto. Pull (reconciliation) deferred to M6. - E2E: Component-level test via TestKit + MSSQL fixture; stubbed gRPC client forwards directly to the central ingest actor. No expansion of
ScadaLinkWebApplicationFactory. - Site writer: Mirror SiteEventLogger —
Channel<PendingAuditEvent>+ background writer Task for sub-ms enqueue durability.
M1 realities baked in:
- Enum vocabulary:
AuditKind.ApiCallfor sync API call;AuditStatus.Deliveredfor success,AuditStatus.Failedfor HTTP non-2xx (permanent OR transient → both Failed for a sync call; cached path differs in M3). The "Status=Success/TransientFailure/PermanentFailure" wording in the roadmap is stale and must be replaced with the new vocabulary. AuditLogRepository.InsertIfNotExistsAsyncrace window — M2 is the first concurrent writer; harden it before AuditLogIngestActor lands.- Keyset tiebreaker test gap from Bundle D — add a same-OccurredAt test in M2.
MsSqlMigrationFixturereusable as-is; promoted to[CollectionDefinition]-shared if multiple test classes need it (defer until actually needed).Xunit.SkippableFact+Skip.IfNot(_fixture.Available, _fixture.SkipReason)for any MSSQL-dependent tests.ScadaLink.AuditLog/Site/andScadaLink.AuditLog/Central/andScadaLink.AuditLog/Telemetry/subfolders. DI extensionAddAuditLogis the registration point.
Tech stack additions:
Microsoft.Data.Sqlite 10.0.7(pinned).Akka.TestKit.Xunit2 1.5.62(pinned).Grpc.Toolsalready configured inScadaLink.Communication.csproj.
Bundles
- Bundle A — Repo race-fix + tiebreaker test (M1 realities catch-up).
- Bundle B — Site SQLite writer + fallback (M2-T1, T2, T3, T4).
- Bundle C — gRPC proto + mapper (M2-T5, T6).
- Bundle D — Telemetry actor + ingest actor + gRPC handler (M2-T7, T8).
- Bundle E — Host wiring (M2-T9).
- Bundle F — ESG emission via ScriptRuntimeContext wrapper (M2-T10).
- Bundle G — Health metric SiteAuditWriteFailures (M2-T11).
- Bundle H — Component-level integration test (M2-T12).
Final cross-bundle reviewer pass, then merge + roadmap update.
Bundle A — Repo race-fix + keyset tiebreaker test
Task A1: Harden InsertIfNotExistsAsync against duplicate-key race
Files:
- Modify:
src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs:30-60— wrap theExecuteSqlInterpolatedAsynccall in atry/catch Microsoft.Data.SqlClient.SqlExceptionthat swallows error numbers 2601 and 2627 (unique-index violation onUX_AuditLog_EventId) and logs at Debug. Other SqlExceptions rethrow. - Modify:
tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs— add:InsertIfNotExistsAsync_ConcurrentDuplicateInserts_ProduceExactlyOneRow— fire 50 parallelInsertIfNotExistsAsynccalls with the sameEventId, assert row count = 1 and no exception escapes.QueryAsync_Keyset_SameOccurredAtUtc_TiebreaksOnEventId— Bundle D reviewer's deferred recommendation. Insert 4 rows with identical OccurredAtUtc but distinct EventIds; page through them with PageSize=2; assert no overlap, correct count, and that the second page's first row's EventId is strictly less than the first page's last row's EventId.
Steps:
- Write failing concurrency test.
- Run: expect SqlException 2601/2627 OR identical-row-count violation.
- Add try/catch in the repo.
- Run: pass.
- Write failing keyset-tiebreaker test.
- Run: depending on EF Core 10's Guid.CompareTo translation, this may already pass — confirm.
- If passing, the test locks in the behavior; commit anyway.
- Commit:
fix(configdb): InsertIfNotExistsAsync swallows duplicate-key races + add keyset tiebreaker test (#23).
Bundle A acceptance: All ConfigurationDatabase.Tests still green; 2 new tests pass.
Bundle B — Site SQLite writer + fallback (mirror SiteEventLogger pattern)
Task B1: SqliteAuditWriter — schema + connection bootstrap
Files:
- Create:
src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs— implementsIAuditWriterper Bundle A's signature (singleTask WriteAsync(AuditEvent evt, CancellationToken ct = default)). Constructor takesIOptions<SqliteAuditWriterOptions>+ILogger. SingleSqliteConnectionopened at construction (Data Source={path};Cache=Shared). Sync_writeLockMonitor-pattern (mirrorsSiteEventLogger.cs:32). InlineInitializeSchema()runsPRAGMA auto_vacuum = INCREMENTAL+CREATE TABLE IF NOT EXISTS AuditLog (...). - Create:
src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs—string DatabasePath = "auditlog.db",int ChannelCapacity = 4096(bounded; drop-oldest applies in Bundle B-T3 ring overflow, but the writer's pending channel is bounded as a safety net),int BatchSize = 256,int FlushIntervalMs = 50. - Create:
tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs.
Schema (20 site columns + ForwardState — IngestedAtUtc is central-only):
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
Tests:
Opens_Creates_AuditLog_Table_With_All_Columns_And_PKOpens_Creates_IX_ForwardState_Occurred_IndexPRAGMA_auto_vacuum_Is_INCREMENTAL
Steps:
- Failing test asserts table + PK + 20 columns + index via
PRAGMA table_info(AuditLog)+PRAGMA index_list(AuditLog). - Implement constructor + InitializeSchema with inline SQL.
- Run: pass.
- Commit:
feat(auditlog): SqliteAuditWriter schema bootstrap (#23).
Task B2: SqliteAuditWriter — Channel + background writer for hot-path
Files:
- Modify:
src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs— addChannel<PendingAuditEvent> _writeQueue(bounded BoundedChannelFullMode.Wait, default capacity 4096), backgroundTask ProcessWriteQueueAsync()launched in constructor.WriteAsyncenqueues + returns the pending'sTaskCompletionSource. The loop reads up toBatchSize, opens a transaction, INSERTs all events, commits, completes the TCS for each. - Pattern mirrors
src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:135-173. - Test:
tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs.
Tests:
WriteAsync_FreshEvent_PersistsWithForwardStatePending— write one event, query SQLite, assert row hasForwardState='Pending'.WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions— fire 1000 parallel WriteAsync, assert row count = 1000 and zero exceptions surface.WriteAsync_LatencyP99_LessThan_5ms_For_Enqueue— assert TCS Task.IsCompleted within reasonable time AFTER awaiting, but the enqueue itself returns near-instantly (verify via a stopwatch around the Channel.Writer.TryWriteAsync).WriteAsync_DuplicateEventId_FirstWriteWins_NoException— insert same EventId twice, assert one row only and no exception (the PRIMARY KEY violation is caught/swallowed in the writer loop).
Steps:
- Failing tests for 1, 2, 4.
- Implement Channel + background loop + transactional batch INSERT.
- Run: pass.
- Commit:
feat(auditlog): SqliteAuditWriter Channel-based hot-path write (#23).
Task B3: RingBufferFallback
Files:
- Create:
src/ScadaLink.AuditLog/Site/RingBufferFallback.cs—Channel<AuditEvent>bounded at 1024 withBoundedChannelFullMode.DropOldest. Exposesbool TryEnqueue(AuditEvent),IAsyncEnumerable<AuditEvent> DrainAsync(CancellationToken), and an eventRingBufferOverflowed(callback for the health counter). - Test:
tests/ScadaLink.AuditLog.Tests/Site/RingBufferFallbackTests.cs.
Tests:
Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflow— invoke 1025 enqueues, assert the OverflowEvent counter increments once, and the surviving 1024 are the latest.DrainAsync_Yields_FIFO_Then_Completes_When_Empty.
Steps:
- Failing tests.
- Implement using
Channel.CreateBounded<AuditEvent>(new BoundedChannelOptions(1024) { FullMode = BoundedChannelFullMode.DropOldest }). - Run: pass.
- Commit:
feat(auditlog): RingBufferFallback with drop-oldest overflow (#23).
Task B4: FallbackAuditWriter — compose primary + ring
Files:
- Create:
src/ScadaLink.AuditLog/Site/FallbackAuditWriter.cs— implementsIAuditWriter. Constructor takes the primarySqliteAuditWriter+RingBufferFallback+IAuditWriteFailureCounter(lightweight DI'd interface, Bundle G implements it asSiteAuditWriteFailurescounter on health metrics). On primary success: returns. On primary throw: increments counter, enqueues into ring (DropOldest), returns success. On the NEXT successful primary call (success after a failure window), drains the ring back through the primary. - Test:
tests/ScadaLink.AuditLog.Tests/Site/FallbackAuditWriterTests.cs.
Tests:
WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess.WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite.WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty.
Steps:
- Failing tests.
- Implement; mock the primary with a
Func<AuditEvent, Task>flip-switch failure. - Run: pass.
- Commit:
feat(auditlog): FallbackAuditWriter compose SQLite + ring (#23).
Bundle B acceptance: 4 tasks merged. ScadaLink.AuditLog.Tests adds ~12+ tests. No regressions.
Bundle C — gRPC proto + mapper
Task C1: Extend sitestream.proto with IngestAuditEvents
Files:
- Modify:
src/ScadaLink.Communication/Protos/sitestream.proto— add the messages and unary RPC. Usegoogle.protobuf.TimestampforOccurredAtUtc; encode enums asstring(matches the EF mapping).
Proposed addition:
message AuditEventDto {
string event_id = 1;
google.protobuf.Timestamp occurred_at_utc = 2;
string channel = 3;
string kind = 4;
string correlation_id = 5; // empty string when null
string source_site_id = 6;
string source_instance_id = 7;
string source_script = 8;
string actor = 9;
string target = 10;
string status = 11;
google.protobuf.Int32Value http_status = 12;
google.protobuf.Int32Value duration_ms = 13;
string error_message = 14;
string error_detail = 15;
string request_summary = 16;
string response_summary = 17;
bool payload_truncated = 18;
string extra = 19;
}
message AuditEventBatch { repeated AuditEventDto events = 1; }
message IngestAck { repeated string accepted_event_ids = 1; }
service SiteStreamService {
// existing rpcs...
rpc IngestAuditEvents(AuditEventBatch) returns (IngestAck);
}
(Use google.protobuf.Int32Value to encode nullable ints; empty string semantics for nullable text fields.)
- Test:
tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs.
Steps:
- Edit proto + rebuild (
dotnet build src/ScadaLink.Communication/). - Failing test round-trips an
AuditEventDtothroughToByteArray()andParser.ParseFrom(); asserts all populated fields survive. - Run: pass.
- Commit:
feat(comms): IngestAuditEvents RPC + AuditEventDto proto (#23).
Task C2: AuditEvent ↔ AuditEventDto mapper
Files:
- Create:
src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs— staticToDto(AuditEvent)andFromDto(AuditEventDto). Handles nullable→empty-string, Timestamp↔DateTime UTC, enum↔string. ForwardState NOT carried in the proto (site-local only; central never sees it). - Test:
tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs.
Tests:
Roundtrip_FullyPopulated_PreservesAllFields.Roundtrip_AllNullableFieldsNull_ProducesEmptyDtoFields.FromDto_EmptyOptionalString_BecomesNullProperty.ToDto_Sets_OccurredAtUtc_As_UtcTimestamp— Round-trip withDateTimeKind.Utcpreserved.
Steps:
- Failing tests.
- Implement.
- Run: pass.
- Commit:
feat(auditlog): AuditEvent ↔ proto mapper (#23).
Bundle C acceptance: Communication.Tests + AuditLog.Tests still green; proto rebuilds cleanly.
Bundle D — SiteAuditTelemetryActor + AuditLogIngestActor + gRPC handler
Task D1: SiteAuditTelemetryActor — drain loop
Files:
- Create:
src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs—ReceiveActor. OnDrain: queriesSqliteAuditWriter.ReadPendingAsync(BatchSize), callsgRPC client.IngestAuditEventsAsync(batch), on ack flips returned EventIds toForwardedviaSqliteAuditWriter.MarkForwardedAsync(eventIds). Re-schedulesDrainself-tick: 5s if ≥1 row drained, 30s otherwise. On gRPC error: re-schedule 5s; rows stay Pending. - Modify:
src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs— addReadPendingAsync(int limit, CancellationToken)returningIReadOnlyList<AuditEvent>(with ForwardState=Pending), andMarkForwardedAsync(IReadOnlyList<Guid> eventIds, CancellationToken). - Create:
src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryOptions.cs—BatchSize=256,BusyIntervalSeconds=5,IdleIntervalSeconds=30. - Test:
tests/ScadaLink.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.csusingTestKit+ NSubstitute-mocked gRPC client.
Tests:
Drain_With_50PendingRows_Sends_OneBatch_Of_50.Drain_Ack_Flips_Rows_To_Forwarded.Drain_GrpcThrows_Rows_StayPending_NextTick_Retries.Drain_Cadence_5s_AfterNonZero_30s_AfterZero(viaTestScheduler).
Steps:
- Failing tests.
- Implement.
- Run: pass.
- Commit:
feat(auditlog): SiteAuditTelemetryActor drain loop (#23).
Task D2: AuditLogIngestActor + gRPC server handler
Files:
- Create:
src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs—ReceiveActoracceptingIngestAuditEventsCommand(IReadOnlyList<AuditEvent> events, IActorRef replyTo). For each event, callsIAuditLogRepository.InsertIfNotExistsAsync(which now swallows duplicates per Bundle A). SetsIngestedAtUtc = DateTime.UtcNowbefore insert (this is the central-side timestamp). Replies withIngestAck(acceptedEventIds)— by spec "accepted" includes already-existed rows (idempotent semantics). - Create:
src/ScadaLink.AuditLog/Central/IngestAuditEventsCommand.cs(Akka message). - Create:
src/ScadaLink.AuditLog/Central/IngestAck.cs(Akka reply). - Modify:
src/ScadaLink.Communication/SiteStreamGrpc/SiteStreamGrpcServer.cs— implementpublic override async Task<IngestAck> IngestAuditEvents(AuditEventBatch request, ServerCallContext context)— Ask the centralAuditLogIngestActorproxy with the deserialized batch, await reply, return. - Modify:
src/ScadaLink.Communication/SiteStreamGrpc/SiteStreamGrpcServer.cs— add a setterSetAuditIngestActor(IActorRef)mirroring howSetNotificationOutboxis wired (per recon: Notification Outbox proxy is handed in viacommService?.SetNotificationOutbox(outboxProxy)). - Test:
tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs. - Test:
tests/ScadaLink.Communication.Tests/SiteStreamIngestAuditEventsTests.cs.
Tests:
Receive_BatchOf5_Calls_Repo_5Times_Acks_All.Receive_BatchWith_AlreadyExistingEvent_AcksAll_NoDoubleInsert(idempotent).Receive_RepoThrowsTransient_Replies_AckExcludingFailedEventIds_LogsError(partial-failure semantics — what gets acked is what was persisted).Receive_Sets_IngestedAtUtc_Before_Insert.gRPC_Handler_Routes_To_Actor_Returns_Reply.
Steps:
- Failing tests.
- Implement actor + gRPC handler.
- Run: pass.
- Commit:
feat(auditlog): AuditLogIngestActor + gRPC handler (#23).
Bundle D acceptance: New actor + gRPC handler tests all green.
Bundle E — Host wiring (central singleton + site actor + dispatcher)
Task E1: Register AuditLogIngestActor + SiteAuditTelemetryActor + dispatcher
Files:
- Modify:
src/ScadaLink.Host/Actors/AkkaHostedService.cs— mirror the Notification Outbox pattern (recon report's exact lines 272-295):- Central role:
AuditLogIngestActorasClusterSingletonManager(singleton name"audit-log-ingest") +ClusterSingletonProxy("audit-log-ingest-proxy"). Hand the proxy toSiteStreamGrpcServer.SetAuditIngestActor(proxy). - Site role:
SiteAuditTelemetryActoras a per-site actor (actorSystem.ActorOf(Props.Create(...)), bound to the dedicated dispatcher (below).
- Central role:
- Modify: HOCON in
src/ScadaLink.Host/Configuration/(the existing akka config file) — add:Applyaudit-telemetry-dispatcher { type = ForkJoinDispatcher throughput = 100 dedicated-thread-pool { thread-count = 2 } }.WithDispatcher("audit-telemetry-dispatcher")toSiteAuditTelemetryActor's Props. - Modify:
src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs:AddAuditLog— register the SqliteAuditWriter+RingBufferFallback+FallbackAuditWriter chain and the actor factories. - Test:
tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs.
Tests:
Central_Host_Starts_With_AuditLogIngest_Singleton_Healthy.Site_Host_Starts_With_SiteAuditTelemetry_Bound_To_DedicatedDispatcher.AuditWriter_Resolves_From_DI_To_FallbackAuditWriter.
Steps:
- Failing tests against current host (which doesn't wire audit).
- Implement wiring.
- Run: pass.
- Commit:
feat(host): register Audit Log #23 singletons with dedicated dispatcher.
Bundle E acceptance: Host.Tests still green; 3 new tests pass.
Bundle F — ESG audit emission via ScriptRuntimeContext wrapper
Task F1: Wrap ExternalSystem.Call in ScriptRuntimeContext to emit audit
Files:
- Modify:
src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs— find the existingExternalSystem.Callmethod (or add one if scripts call through a dynamic API surface). Inside, after_externalSystemClient.CallAsync(...)returns OR throws, build theAuditEvent(channel=ApiOutbound, kind=ApiCall, status=Deliveredfor success,Failedfor HTTP non-2xx or exception, populateTarget=$"{systemName}.{methodName}",SourceSiteId={siteId},SourceInstanceId={instanceName},SourceScript={sourceScript},DurationMs={stopwatch},HttpStatus,ErrorMessage). Call_auditWriter.WriteAsync(evt)inside a try/catch that swallows + logs at Warning + incrementsSiteAuditWriteFailures(via the same counter Bundle G defines). Re-throw the original ExternalSystem exception (if any) so the script sees its original error path unchanged. - Modify:
src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.csconstructor — injectIAuditWriter. - Modify:
src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs— resolve and passIAuditWriterinto the ScriptRuntimeContext. - Test:
tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs.
Tests:
Call_Success_EmitsOneEvent_Channel_ApiOutbound_Kind_ApiCall_Status_Delivered.Call_HTTP500_EmitsEvent_Status_Failed_HttpStatus_500_ErrorMessage_Set.Call_HTTP400_EmitsEvent_Status_Failed_HttpStatus_400.Call_ClientThrows_NetworkError_EmitsEvent_Status_Failed_ErrorMessage_SetFromException.AuditWriter_Throws_Script_Call_Returns_Original_Result_Unchanged_Audit_Failure_Counter_Incremented.Provenance_Populated_FromContext— SourceInstanceId, SourceScript, SourceSiteId all match the ScriptRuntimeContext's values.
Steps:
- Failing tests.
- Implement wrapper + provenance threading.
- Run: pass.
- Commit:
feat(siteruntime): ExternalSystem.Call emits Audit Log #23 event on every sync call.
Bundle F acceptance: SiteRuntime.Tests still green; 6 new tests.
Bundle G — Health metric SiteAuditWriteFailures
Task G1: Counter + DI surface
Files:
- Create:
src/ScadaLink.AuditLog/Site/IAuditWriteFailureCounter.cs—void Increment();. Bundle B'sFallbackAuditWriteralready takes this. - Modify:
src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs— addint _siteAuditWriteFailuresfield +IncrementSiteAuditWriteFailures()method usingInterlocked.Increment. Expose via a snapshot read. - Modify:
src/ScadaLink.HealthMonitoring/SiteHealthState.cs— addSiteAuditWriteFailuresproperty to the report payload. - Implementation: a small adapter class
HealthMetricsAuditWriteFailureCounter : IAuditWriteFailureCounterregistered in DI that bridges toISiteHealthCollector.IncrementSiteAuditWriteFailures(). - Test:
tests/ScadaLink.HealthMonitoring.Tests/SiteAuditWriteFailuresMetricTests.cs.
Tests:
Increment_Three_Times_Counter_Reports_3.Report_Payload_Includes_SiteAuditWriteFailures.
Steps:
- Failing tests.
- Implement counter + adapter + DI registration.
- Run: pass.
- Commit:
feat(health): SiteAuditWriteFailures counter (#23).
Bundle G acceptance: HealthMonitoring.Tests still green; 2 new tests.
Bundle H — Component-level integration test
Task H1: End-to-end via TestKit + MSSQL fixture
Files:
- Create:
tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs— usesMsSqlMigrationFixture(the M1 reusable fixture; depend onXunit.SkippableFact):- Brings up
SqliteAuditWriteragainst:memory:. - Brings up
SiteAuditTelemetryActorvia TestKit. - Brings up
AuditLogIngestActorvia TestKit, configured with the MSSQLIAuditLogRepositoryfrom M1. - Stubs the gRPC client by overriding the actor's gRPC dependency with a direct
IActorRef-backed mock that forwardsIngestAuditEventsdirectly to the central actor. - Writes one
AuditEventvia the FallbackAuditWriter. - Drives a
Draintick on the telemetry actor. - Asserts the row appears in the MS SQL
AuditLogtable within 5 seconds viaIAuditLogRepository.QueryAsync.
- Brings up
Steps:
- Failing test (telemetry not yet wired).
- Wire the components together via the test harness.
- Run: pass.
- Commit:
test(auditlog): end-to-end sync-call emission via TestKit + MSSQL fixture (#23).
Bundle H acceptance: New test passes when MSSQL container is up; skips cleanly when down.
Final cross-bundle review
After Bundles A–H, dispatch a final reviewer agent with the same template as M1's. Acceptance gate: full dotnet test ScadaLink.slnx green. Then merge --no-ff with summary; update M3–M8 with M2 realities; status paragraph; proceed to M3.