feat(scaudit): SiteCallAuditActor minimum surface (#22, #23 M3)

Bundle C of Audit Log #23 M3.

Adds the ScadaLink.SiteCallAudit project + matching tests project, mirroring
the ScadaLink.AuditLog scaffolding pattern (net10.0, central package
management, InternalsVisibleTo to the tests assembly).

SiteCallAuditActor is the central singleton entry point for Site Call Audit
(#22): it receives UpsertSiteCallCommand and persists the SiteCall via
ISiteCallAuditRepository.UpsertAsync (monotonic, idempotent — out-of-order
or duplicate updates are silent no-ops at the repo). Audit-write failures
NEVER abort the user-facing action (CLAUDE.md): repository throws are
caught + logged, the actor replies Accepted=false, and the singleton stays
alive (Resume supervisor strategy as defence in depth).

Two constructors mirror AuditLogIngestActor:
- IServiceProvider production constructor resolves the scoped EF repository
  from a fresh DI scope per message.
- ISiteCallAuditRepository test constructor injects a concrete repository so
  the TestKit tests exercise the real monotonic-upsert SQL end to end.

UpsertSiteCallCommand + UpsertSiteCallReply live in ScadaLink.Commons (same
home as IngestAuditEventsCommand) so Bundle D's gRPC server can construct
them without taking a project reference on the actor's host project.

AddSiteCallAudit() is a placeholder for symmetry with AddAuditLog /
AddNotificationOutbox; Bundle F will populate it with the actor's Props
factory + options bindings.

Tests (Akka.TestKit.Xunit2 + MsSqlMigrationFixture via project ref to
ScadaLink.ConfigurationDatabase.Tests, mirroring Bundle D2):
- Receive_UpsertSiteCallCommand_Persists_Replies_Accepted
- Receive_DuplicateUpsert_OlderStatus_NoOp_StillRepliesAccepted (idempotency)
- Receive_RepoThrowsTransient_RepliesAccepted_False_ActorStaysAlive

Reconciliation, KPIs, and the central->site Retry/Discard relay are
deferred per CLAUDE.md scope discipline.

ScadaLink.slnx updated to include both new projects.

All 3 new tests pass against the running infra/mssql container; full suite
(2683 tests across 27 projects) passes with no regressions.
This commit is contained in:
Joseph Doherty
2026-05-20 14:18:49 -04:00
parent bedfa6b8f3
commit de110f8b42
8 changed files with 517 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.Commons.Messages.Audit;
/// <summary>
/// Akka message sent to the central <c>SiteCallAuditActor</c> (Site Call Audit
/// #22, Audit Log #23 M3 Bundle C) carrying one <see cref="SiteCall"/> row to
/// be persisted via <c>ISiteCallAuditRepository.UpsertAsync</c>. The repository
/// performs an insert-if-not-exists then monotonic update — duplicate gRPC
/// packets and reconciliation pulls can both feed the actor without rolling
/// state back.
/// </summary>
/// <remarks>
/// Lives in <c>ScadaLink.Commons</c> rather than <c>ScadaLink.SiteCallAudit</c>
/// so the gRPC server in <c>ScadaLink.Communication</c> can construct it
/// without taking a project reference on the actor's host project (Bundle D
/// adds the IngestCachedTelemetry RPC that will Tell this command).
/// </remarks>
public sealed record UpsertSiteCallCommand(SiteCall SiteCall);

View File

@@ -0,0 +1,14 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Messages.Audit;
/// <summary>
/// Reply from the central <c>SiteCallAuditActor</c> for an
/// <see cref="UpsertSiteCallCommand"/>. <see cref="Accepted"/> is <c>true</c>
/// when the upsert reached the repository without throwing (including the
/// monotonic-no-op case where the stored status' rank wins) and <c>false</c>
/// when persistence raised an exception. The actor itself stays alive in
/// either case — audit-write failures must NEVER abort the user-facing action
/// (Audit Log #23 §13).
/// </summary>
public sealed record UpsertSiteCallReply(TrackedOperationId TrackedOperationId, bool Accepted);

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<!-- SiteCallAuditActor is an Akka actor (central singleton in Bundle F); Akka is an explicit dependency. -->
<PackageReference Include="Akka" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
<!-- Site Call Audit (#22) sits alongside Notification Outbox (#21) and Audit Log (#23).
ISiteCallAuditRepository is registered by ScadaLink.ConfigurationDatabase; the
project reference is documented here so the actor's scope-per-message
GetRequiredService<ISiteCallAuditRepository>() compiles. -->
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ScadaLink.SiteCallAudit.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,39 @@
using Microsoft.Extensions.DependencyInjection;
namespace ScadaLink.SiteCallAudit;
/// <summary>
/// Composition root for the Site Call Audit (#22) component.
/// </summary>
/// <remarks>
/// <para>
/// M3 Bundle C ships the ingest-only minimum surface (the actor itself); the
/// full DI surface — reconciliation puller, KPI projector, central→site
/// Retry/Discard relay, options + validators — is deferred to a follow-up.
/// </para>
/// <para>
/// The repository (<c>ISiteCallAuditRepository</c>) is registered by
/// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
/// so callers (the Host on the central node) must also call that. The actor's
/// <c>Props</c> are wired up in Host registration (Bundle F); this extension
/// is currently a no-op placeholder kept for symmetry with the AuditLog and
/// NotificationOutbox composition roots — adding it now means consumers can
/// reference the method without re-touching the Host project later.
/// </para>
/// </remarks>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers Site Call Audit (#22) services. Currently a no-op
/// placeholder — Bundle F will populate this with the actor's Props
/// factory + options bindings. The method is exposed now so the Host
/// wiring call already exists at the API boundary.
/// </summary>
public static IServiceCollection AddSiteCallAudit(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// Actor props are constructed in Host wiring (Bundle F). This
// extension is a placeholder for future config + DI.
return services;
}
}

View File

@@ -0,0 +1,140 @@
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Audit;
namespace ScadaLink.SiteCallAudit;
/// <summary>
/// Central singleton for Site Call Audit (#22). Receives
/// <see cref="UpsertSiteCallCommand"/> messages and persists each
/// <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/> row via
/// <see cref="ISiteCallAuditRepository.UpsertAsync"/> — idempotent monotonic
/// upsert. Out-of-order or duplicate updates are silent no-ops at the
/// repository layer; the actor always replies <see cref="UpsertSiteCallReply"/>
/// with <c>Accepted=true</c> in that case because storage state is consistent
/// and the site is free to consider its packet acked.
/// </summary>
/// <remarks>
/// <para>
/// M3 ships the minimum surface: ingest only. Reconciliation, KPIs, and
/// central→site Retry/Discard relay are deferred (per CLAUDE.md scope
/// discipline — Site Call Audit's KPIs and the Retry/Discard relay land in a
/// follow-up).
/// </para>
/// <para>
/// Per CLAUDE.md "audit-write failure NEVER aborts the user-facing action" —
/// the actor catches every exception from the repository call and replies
/// <c>Accepted=false</c> without rethrowing, so the central singleton stays
/// alive. The <see cref="SupervisorStrategy"/> uses <c>Resume</c> so an
/// unexpected throw before the catch (defence in depth) does not restart the
/// actor and reset in-flight state.
/// </para>
/// <para>
/// Two constructors exist for the same reason as
/// <c>AuditLogIngestActor</c>: production wiring (Bundle F) resolves the
/// scoped EF repository from a fresh DI scope per message because the actor
/// is a long-lived cluster singleton, while tests inject a concrete
/// <see cref="ISiteCallAuditRepository"/> against a per-test MSSQL fixture
/// so the actor exercises the real monotonic upsert SQL end to end.
/// </para>
/// </remarks>
public class SiteCallAuditActor : ReceiveActor
{
private readonly IServiceProvider? _serviceProvider;
private readonly ISiteCallAuditRepository? _injectedRepository;
private readonly ILogger<SiteCallAuditActor> _logger;
/// <summary>
/// Test-mode constructor — injects a concrete repository instance whose
/// lifetime exceeds the test, so the actor reuses the same instance
/// across every message. Used by Bundle C's MSSQL-backed TestKit fixture.
/// </summary>
public SiteCallAuditActor(
ISiteCallAuditRepository repository,
ILogger<SiteCallAuditActor> logger)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
_injectedRepository = repository;
_logger = logger;
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
}
/// <summary>
/// Production constructor — resolves <see cref="ISiteCallAuditRepository"/>
/// from a fresh DI scope per message because the repository is a scoped EF
/// Core service registered by <c>AddConfigurationDatabase</c>. The actor
/// itself is a long-lived cluster singleton, so it cannot hold a scope
/// across messages.
/// </summary>
public SiteCallAuditActor(
IServiceProvider serviceProvider,
ILogger<SiteCallAuditActor> logger)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(logger);
_serviceProvider = serviceProvider;
_logger = logger;
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
}
/// <summary>
/// Audit-write failures are best-effort by design (CLAUDE.md §Audit): a
/// thrown exception in the upsert pipeline must not crash the actor.
/// Resume keeps the actor's state intact so the next packet is processed
/// against the same repository instance.
/// </summary>
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(maxNrOfRetries: 0, withinTimeRange: TimeSpan.Zero, decider:
Akka.Actor.SupervisorStrategy.DefaultDecider);
}
private async Task OnUpsertAsync(UpsertSiteCallCommand cmd)
{
// Sender is captured before the first await — Akka resets Sender
// between message dispatches, so a post-await Tell would go to
// DeadLetters.
var replyTo = Sender;
var id = cmd.SiteCall.TrackedOperationId;
// Scope-per-message mirrors AuditLogIngestActor — production EF
// repository is scoped; the injected-repository mode (tests) skips
// the scope entirely.
IServiceScope? scope = null;
ISiteCallAuditRepository repository;
if (_injectedRepository is not null)
{
repository = _injectedRepository;
}
else
{
scope = _serviceProvider!.CreateScope();
repository = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
}
try
{
await repository.UpsertAsync(cmd.SiteCall).ConfigureAwait(false);
replyTo.Tell(new UpsertSiteCallReply(id, Accepted: true));
}
catch (Exception ex)
{
// Per CLAUDE.md: audit-write failure NEVER aborts the user-facing
// action — log and reply Accepted=false; do NOT rethrow (the
// central singleton MUST stay alive).
_logger.LogError(ex, "SiteCallAudit upsert failed for {TrackedOperationId}", id);
replyTo.Tell(new UpsertSiteCallReply(id, Accepted: false));
}
finally
{
scope?.Dispose();
}
}
}