diff --git a/ScadaLink.slnx b/ScadaLink.slnx
index 21b4f3a..3a70a13 100644
--- a/ScadaLink.slnx
+++ b/ScadaLink.slnx
@@ -12,6 +12,7 @@
+
@@ -35,6 +36,7 @@
+
diff --git a/docs/plans/2026-05-20-auditlog-m3-cached-operations.md b/docs/plans/2026-05-20-auditlog-m3-cached-operations.md
new file mode 100644
index 0000000..a9594a0
--- /dev/null
+++ b/docs/plans/2026-05-20-auditlog-m3-cached-operations.md
@@ -0,0 +1,212 @@
+# Audit Log #23 — M3 Cached Operations + Dual-Write Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (bundled cadence per `feedback_subagent_cadence`).
+
+**Goal:** Cached external calls (`ExternalSystem.CachedCall`) and cached DB writes (`Database.CachedWrite`) each produce 4+ audit rows per operation (`CachedSubmit` → `ApiCallCached`/`DbWriteCached` × N attempts with statuses `Forwarded` then `Attempted` then `Delivered`/`Failed` → `CachedResolve` terminal) AND a `SiteCalls` row at central. Combined telemetry: site emits one packet per lifecycle event carrying both the AuditEvent and the SiteCalls upsert; central writes both in one MS SQL transaction. Audit-write failure never aborts the script.
+
+**Recommended-defaults applied:**
+- Telemetry proto: **new top-level RPC `IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck)`** (sitestream.proto), separate from the M2 `IngestAuditEvents` to keep payload shapes distinct.
+- Forwarder: **separate `CachedCallTelemetryForwarder`** actor (or static dispatcher hooking into the existing `SiteAuditTelemetryActor`'s SQLite queue) — write the audit row + tracking row in one SQLite transaction, then let the existing telemetry actor drain both via the new RPC. Reuse the M2 Channel/SQLite hot-path infrastructure; do NOT introduce a parallel writer.
+- Provenance: mirror M2's `ScriptRuntimeContext` wrapper pattern — ScriptRuntimeContext's cached-call helpers capture instance/script/site and feed the combined packet.
+- IntegrationTests E2E: same component-level pattern as M2 Bundle H (`DirectActorSiteStreamAuditClient`), but extracted into `tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/` for reuse.
+
+**M2 realities baked in (from roadmap line 446-459):**
+- Use M1 vocabulary: `AuditKind.CachedSubmit` (enqueue), `AuditKind.ApiCallCached` / `AuditKind.DbWriteCached` (each attempt + post-forward), `AuditKind.CachedResolve` (terminal). `AuditStatus.Submitted` → `Forwarded` → `Attempted` × N → `Delivered`/`Failed`/`Parked`/`Discarded`. NO `CachedEnqueued`/`CachedAttempt`/`CachedTerminal` strings appear in code (those are pre-M1 spec wording the roadmap text still mentions; honor the enum vocabulary).
+- NoOpSiteStreamAuditClient still in production until M6; E2E tests use the M2 Bundle H pattern.
+- AuditEventMapper duplication note from M2: M3 should move the mapper into Commons (or document the gRPC inline duplication) since M3 adds a SECOND gRPC handler with the same DTO→entity translation work.
+- CachedCallTelemetry message creates from scratch (additive per Commons REQ-COM-5a) — NOT renamed to CachedOperationTelemetry.
+
+---
+
+## Bundles
+
+- **Bundle A — Commons types + tracking store** (T1, T2, T3, T4): TrackedOperationId, OperationTrackingStore, Tracking.Status API, CachedCallTelemetry message.
+- **Bundle B — SiteCalls table EF + migration + repo** (T5, T6, T7).
+- **Bundle C — SiteCallAudit project + actor** (T8).
+- **Bundle D — Proto + central dual-write transaction** (T9, T10).
+- **Bundle E — ESG / DB-gateway / S&F emissions** (T11, T12, T13, T14).
+- **Bundle F — Host registration** (T15).
+- **Bundle G — Integration tests** (T16, T17, T18).
+
+Final cross-bundle reviewer + merge to main.
+
+---
+
+## Bundle A — Commons types + tracking store
+
+### Task A1: TrackedOperationId strong-typed ID
+File: `src/ScadaLink.Commons/Types/TrackedOperationId.cs` — `public readonly record struct TrackedOperationId(Guid Value)`. Static `New()`, `Parse(string)`, `ToString()` returns Value.ToString("D"). Implicit conversion from Guid via `From(Guid)` (no operator implicit because record struct doesn't allow). Tests in `tests/ScadaLink.Commons.Tests/Types/TrackedOperationIdTests.cs`. Commit: `feat(commons): TrackedOperationId strong type (#23 M3)`.
+
+### Task A2: OperationTrackingStore (site-local SQLite)
+File: `src/ScadaLink.Commons/Interfaces/IOperationTrackingStore.cs` — `RecordEnqueueAsync`, `RecordAttemptAsync`, `RecordTerminalAsync`, `GetStatusAsync(TrackedOperationId)`, `PurgeTerminalAsync(olderThanUtc)`.
+File: `src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs` — SQLite-backed, mirror SqliteAuditWriter pattern: Channel + background writer Task + write-lock. Schema:
+```sql
+CREATE TABLE IF NOT EXISTS OperationTracking (
+ TrackedOperationId TEXT NOT NULL PRIMARY KEY,
+ Kind TEXT NOT NULL,
+ TargetSummary TEXT NULL,
+ Status TEXT NOT NULL,
+ RetryCount INTEGER NOT NULL DEFAULT 0,
+ LastError TEXT NULL,
+ HttpStatus INTEGER NULL,
+ CreatedAtUtc TEXT NOT NULL,
+ UpdatedAtUtc TEXT NOT NULL,
+ TerminalAtUtc TEXT NULL,
+ SourceInstanceId TEXT NULL,
+ SourceScript TEXT NULL);
+CREATE INDEX IF NOT EXISTS IX_OperationTracking_Status_Updated ON OperationTracking(Status, UpdatedAtUtc);
+```
+Tests: schema, insert+update sequence, terminal purge (only terminal rows older than threshold). Commit: `feat(siteruntime): OperationTrackingStore site-local SQLite (#23 M3)`.
+
+### Task A3: Tracking.Status script API
+File: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs` — add a `Tracking` accessor exposing `Status(TrackedOperationId)` reading via `IOperationTrackingStore.GetStatusAsync`. Returns a `TrackingStatusSnapshot` record (Commons/Types) with `Status`, `RetryCount`, `LastError`, `CreatedAtUtc`, `UpdatedAtUtc`, `TerminalAtUtc`. Returns null for unknown IDs.
+Tests: known, unknown, terminal IDs. Commit: `feat(siteruntime): Tracking.Status script API (#23 M3)`.
+
+### Task A4: CachedCallTelemetry Commons message
+File: `src/ScadaLink.Commons/Messages/Integration/CachedCallTelemetry.cs` — `public sealed record CachedCallTelemetry(TrackedOperationId TrackedOperationId, AuditEvent Audit, SiteCallOperational Operational)` plus `SiteCallOperational` record (TrackedOperationId, Channel, Target, SourceSite, Status, RetryCount, LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc?).
+Tests: round-trip; lifecycle-specific construction (Submit/Attempted/Resolve). Commit: `feat(commons): CachedCallTelemetry combined operational+audit packet (#23 M3)`.
+
+---
+
+## Bundle B — SiteCalls EF + migration + repo
+
+### Task B1: SiteCall entity + EF mapping
+File: `src/ScadaLink.Commons/Entities/Audit/SiteCall.cs` — `public sealed record SiteCall` with fields per `SiteCallOperational` plus `IngestedAtUtc`.
+File: `src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs` — table `SiteCalls`, PK on `TrackedOperationId`, indexes `IX_SiteCalls_Source_Created` on (SourceSite, CreatedAtUtc), `IX_SiteCalls_Status_Updated` on (Status, UpdatedAtUtc).
+Modify: `ScadaLinkDbContext.cs` — `public DbSet SiteCalls => Set();`.
+Tests as M1 pattern. Commit: `feat(configdb): map SiteCall to SiteCalls table (#23 M3)`.
+
+### Task B2: AddSiteCallsTable migration
+Generate via `dotnet ef migrations add AddSiteCallsTable --project src/ScadaLink.ConfigurationDatabase --startup-project src/ScadaLink.Host`. No partitioning (operational state, not audit). Use MsSqlMigrationFixture for integration test. Commit: `feat(configdb): add SiteCalls migration (#23 M3)`.
+
+### Task B3: ISiteCallAuditRepository + EF impl
+File: `src/ScadaLink.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs` — `UpsertAsync(SiteCall)` with **monotonic status progression** (later status wins; earlier status is no-op), `GetAsync(TrackedOperationId)`, `QueryAsync(filter, paging)`, `PurgeTerminalAsync(olderThanUtc)`.
+File: `src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs` — implement via `MERGE` or `INSERT ... WHERE NOT EXISTS` + `UPDATE WHERE TerminalAtUtc IS NULL AND `. Tests use MsSqlMigrationFixture. Commit: `feat(configdb): ISiteCallAuditRepository + EF impl (#23 M3)`.
+
+---
+
+## Bundle C — SiteCallAudit project + actor
+
+### Task C1: ScadaLink.SiteCallAudit project + actor
+Create: `src/ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj` (mirrors ScadaLink.AuditLog csproj style — net10.0, references Commons + ConfigurationDatabase).
+Create: `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` — central singleton actor handling `UpsertSiteCallCommand(SiteCall siteCall)` by calling `ISiteCallAuditRepository.UpsertAsync` (scope-per-message via IServiceProvider, mirror AuditLogIngestActor). Idempotent via repo's monotonic upsert.
+Create: `src/ScadaLink.SiteCallAudit/ServiceCollectionExtensions.cs` — `AddSiteCallAudit()` registering actor props factory.
+Create: `tests/ScadaLink.SiteCallAudit.Tests/` project.
+Modify: `ScadaLink.slnx` — add src + tests entries.
+Commit: `feat(scaudit): SiteCallAuditActor minimum surface (#22, #23 M3)`.
+
+---
+
+## Bundle D — Proto + central dual-write transaction
+
+### Task D1: Extend sitestream.proto with IngestCachedTelemetry RPC
+Follow the documented protobuf regen procedure from M2 Bundle C (temporarily uncomment ItemGroup, build, copy back, recomment). Add:
+```proto
+message SiteCallOperationalDto {
+ string tracked_operation_id = 1;
+ string channel = 2;
+ string target = 3;
+ string source_site = 4;
+ string status = 5;
+ int32 retry_count = 6;
+ string last_error = 7;
+ google.protobuf.Int32Value http_status = 8;
+ google.protobuf.Timestamp created_at_utc = 9;
+ google.protobuf.Timestamp updated_at_utc = 10;
+ google.protobuf.Timestamp terminal_at_utc = 11; // null when active
+}
+message CachedTelemetryPacket {
+ AuditEventDto audit_event = 1;
+ SiteCallOperationalDto operational = 2;
+}
+message CachedTelemetryBatch { repeated CachedTelemetryPacket packets = 1; }
+
+service SiteStreamService {
+ rpc IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck);
+}
+```
+Test round-trips. Commit: `feat(comms): IngestCachedTelemetry RPC + CachedTelemetryPacket proto (#23 M3)`.
+
+### Task D2: Dual-write transaction in AuditLogIngestActor
+File: `src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs` (extend) — add `IngestCachedTelemetryCommand` handler. Inside one `DbContext.Database.BeginTransactionAsync()`:
+1. Call `IAuditLogRepository.InsertIfNotExistsAsync(auditEvent)` (idempotent already from M2 Bundle A).
+2. Call `ISiteCallAuditRepository.UpsertAsync(siteCallOperational)` (monotonic).
+3. Commit on both-success; rollback on either-throw (the central singleton SUPERVISES — actor doesn't crash).
+4. Reply `IngestAck(acceptedIds)`.
+
+Modify: `src/ScadaLink.Communication/SiteStreamGrpc/SiteStreamGrpcServer.cs` — implement `IngestCachedTelemetry` gRPC handler routing to actor. Same inline FromDto pattern as M2 (move to mapper if time permits per M2 reviewer recommendation).
+
+Add: `src/ScadaLink.Commons/Messages/Audit/IngestCachedTelemetryCommand.cs` and `IngestCachedTelemetryReply.cs` (Akka messages).
+
+Tests:
+- Single packet → 1 AuditLog + 1 SiteCalls row.
+- Duplicate `EventId` + same status → AuditLog no-op, SiteCalls no-op (monotonic), no error.
+- Duplicate `EventId` + ADVANCED status → AuditLog no-op, SiteCalls updates.
+- SiteCalls upsert throws → AuditLog rolled back (no orphan).
+- AuditLog throws (non-duplicate) → SiteCalls rolled back.
+
+Commit: `feat(auditlog): combined telemetry dual-write transaction (#23 M3)`.
+
+---
+
+## Bundle E — ESG / DB / S&F lifecycle emissions
+
+### Task E1: ScriptRuntimeContext.ExternalSystem.CachedCall wrapper
+Mirror M2 Bundle F's `Call` wrapper. Differences:
+- Emit on enqueue: AuditEvent(Kind=CachedSubmit, Status=Submitted) + SiteCallOperational(Status=Submitted, RetryCount=0).
+- Calls `_externalSystemClient.CachedCallAsync` (resolves what S&F existing API surface looks like — discover by reading ExternalSystemClient).
+- Returns a `TrackedOperationId` immediately (a TrackedOperationId tracking handle).
+- Hands the operation to the existing StoreAndForward retry loop.
+
+For the per-attempt + terminal emissions, hook into the S&F dispatch loop (Bundle E2/E3).
+
+### Task E2: S&F retry-loop emission
+Find the S&F retry-attempt callback site in `src/ScadaLink.StoreAndForward/`. On each attempt (success/transient/permanent):
+- Build AuditEvent(Kind=ApiCallCached or DbWriteCached, Status=Attempted).
+- Build SiteCallOperational(Status=Attempted, RetryCount=N, LastError, HttpStatus).
+- Hand to `CachedCallTelemetryForwarder` which writes both to SQLite (AuditLog + OperationTracking tables, in one SQLite transaction) and lets SiteAuditTelemetryActor's drain loop push them.
+
+### Task E3: S&F terminal-state emission
+On final state transition (Delivered / Failed / Parked / Discarded):
+- Build AuditEvent(Kind=CachedResolve, Status={final state}).
+- Build SiteCallOperational(Status={final state}, TerminalAtUtc=DateTime.UtcNow).
+- Forward.
+
+### Task E4: Database.CachedWrite mirror
+Same three-event pattern but Channel=DbOutbound, Kind=DbWriteCached for attempts, Kind=CachedSubmit for enqueue, Kind=CachedResolve for terminal.
+
+Tests in ExternalSystemGateway.Tests + StoreAndForward.Tests.
+
+Commit (bundle-level): one commit per task, descriptive messages following M2 style.
+
+---
+
+## Bundle F — Host registration
+
+### Task F1: Register SiteCallAuditActor central singleton
+File: `src/ScadaLink.Host/Actors/AkkaHostedService.cs` — register `SiteCallAuditActor` central singleton + proxy alongside `AuditLogIngestActor`. Hand the proxy to `SiteStreamGrpcServer.SetSiteCallAuditActor(proxy)` (mirroring `SetAuditIngestActor`).
+File: `src/ScadaLink.Host/Program.cs` — call `.AddSiteCallAudit()` on the central role's services.
+Tests in `tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs` (extend).
+Commit: `feat(host): register SiteCallAuditActor central singleton (#22, #23 M3)`.
+
+---
+
+## Bundle G — Integration tests
+
+### Task G1: Extract DirectActorSiteStreamAuditClient to shared infrastructure
+Move from `tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs` private inner class into `tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/DirectActorSiteStreamAuditClient.cs`. Extend to also implement the new `IngestCachedTelemetryAsync` method (mirror pattern).
+
+### Task G2: Cached call E2E test
+File: `tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs` (use AuditLog.Tests, not IntegrationTests, because the existing IntegrationTests harness disables Akka per M2 reality). Test: cached call that fails twice then succeeds produces 5 AuditLog rows (1 Submit + 1 Forwarded + 2 Attempted + 1 Resolve) + 1 SiteCalls row (Status=Delivered) + Tracking.Status reports Delivered.
+
+### Task G3: Cached DB write E2E test
+File: `tests/ScadaLink.AuditLog.Tests/Integration/CachedWriteCombinedTelemetryTests.cs`. Mirror G2 for DB.
+
+### Task G4: Idempotency test
+File: `tests/ScadaLink.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs`. Send the same packet twice; assert exactly 1 AuditLog row + 1 SiteCalls row.
+
+---
+
+## Final cross-bundle review + merge
+
+Same template as M1/M2.
diff --git a/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs b/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs
index 01496bb..2b2c580 100644
--- a/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs
+++ b/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Audit;
+using ScadaLink.ConfigurationDatabase;
namespace ScadaLink.AuditLog.Central;
@@ -61,6 +62,11 @@ public class AuditLogIngestActor : ReceiveActor
_logger = logger;
ReceiveAsync(OnIngestAsync);
+ // The single-repository test ctor cannot service the M3 dual-write —
+ // it has no SiteCalls repo and no DbContext. The handler still
+ // registers (so callers don't dead-letter) but replies empty so the
+ // test surface stays explicit about what this ctor supports.
+ ReceiveAsync(OnCachedTelemetryWithoutDualWriteAsync);
}
///
@@ -81,6 +87,7 @@ public class AuditLogIngestActor : ReceiveActor
_logger = logger;
ReceiveAsync(OnIngestAsync);
+ ReceiveAsync(OnCachedTelemetryAsync);
}
///
@@ -150,4 +157,98 @@ public class AuditLogIngestActor : ReceiveActor
replyTo.Tell(new IngestAuditEventsReply(accepted));
}
+
+ ///
+ /// M3 dual-write handler. For every the
+ /// actor opens a fresh MS SQL transaction, inserts the AuditLog row
+ /// idempotently AND upserts the SiteCalls row monotonically. Both succeed
+ /// or both roll back, so the audit and operational mirrors never drift
+ /// mid-row. The IngestedAtUtc stamp is unified between the two rows so a
+ /// downstream join lines up cleanly.
+ ///
+ ///
+ /// Per-entry isolation — one entry's failed transaction does NOT abort
+ /// other entries in the batch (each gets its own
+ ///
+ /// scope and a try/catch around it). Audit-write failure NEVER aborts the
+ /// user-facing action — the site keeps the row Pending and retries on the
+ /// next drain.
+ ///
+ private async Task OnCachedTelemetryAsync(IngestCachedTelemetryCommand cmd)
+ {
+ var replyTo = Sender;
+ var accepted = new List(cmd.Entries.Count);
+
+ try
+ {
+ await using var scope = _serviceProvider!.CreateAsyncScope();
+ var auditRepo = scope.ServiceProvider.GetRequiredService();
+ var siteCallRepo = scope.ServiceProvider.GetRequiredService();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ foreach (var entry in cmd.Entries)
+ {
+ try
+ {
+ await using var tx = await dbContext.Database
+ .BeginTransactionAsync()
+ .ConfigureAwait(false);
+
+ // Stamp IngestedAtUtc on both rows from a single
+ // central-side instant so a join on the two tables sees
+ // matching timestamps (debugging convenience, not a
+ // correctness invariant).
+ var ingestedAt = DateTime.UtcNow;
+ var auditStamped = entry.Audit with { IngestedAtUtc = ingestedAt };
+ var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
+
+ await auditRepo.InsertIfNotExistsAsync(auditStamped)
+ .ConfigureAwait(false);
+ await siteCallRepo.UpsertAsync(siteCallStamped)
+ .ConfigureAwait(false);
+
+ await tx.CommitAsync().ConfigureAwait(false);
+ accepted.Add(entry.Audit.EventId);
+ }
+ catch (Exception ex)
+ {
+ // Both rows rolled back via the disposing transaction. The
+ // EventId is NOT added to `accepted` so the site keeps its
+ // row Pending and retries on the next drain. Other entries
+ // in the batch continue with their own transactions.
+ _logger.LogError(
+ ex,
+ "Combined telemetry dual-write failed for AuditEvent {EventId} / TrackedOperationId {TrackedOpId}; rolled back.",
+ entry.Audit.EventId,
+ entry.SiteCall.TrackedOperationId);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ // Resolving the scope itself threw (e.g. DI mis-wiring). Log and
+ // reply with whatever we managed to accept (likely empty) — the
+ // central singleton MUST stay alive.
+ _logger.LogError(
+ ex,
+ "Combined telemetry batch ingest failed before per-entry processing.");
+ }
+
+ replyTo.Tell(new IngestCachedTelemetryReply(accepted));
+ }
+
+ ///
+ /// Fallback handler installed on the single-repository test ctor — that
+ /// ctor has no DbContext and no , so
+ /// it cannot service the dual-write. Logs a warning and replies with an
+ /// empty ack so callers fall through to their retry path.
+ ///
+ private Task OnCachedTelemetryWithoutDualWriteAsync(IngestCachedTelemetryCommand cmd)
+ {
+ _logger.LogWarning(
+ "AuditLogIngestActor received IngestCachedTelemetryCommand on the single-repository ctor; dual-write requires the IServiceProvider ctor. Replying with empty ack ({Count} entries).",
+ cmd.Entries.Count);
+ Sender.Tell(new IngestCachedTelemetryReply(Array.Empty()));
+ return Task.CompletedTask;
+ }
}
diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
index 34d3a23..e420f4f 100644
--- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
+++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
@@ -102,6 +102,33 @@ public static class ServiceCollectionExtensions
// SiteAuditTelemetryActor's Props.Create call.
services.AddSingleton();
+ // M3 Bundle F: site-side dual emitter for cached-call lifecycle
+ // telemetry. ScriptRuntimeContext.ExternalSystem.CachedCall /
+ // Database.CachedWrite resolves this through DI and pushes one combined
+ // packet per lifecycle event; the forwarder writes the audit half
+ // through IAuditWriter and the operational half through the
+ // IOperationTrackingStore. The audit writer is always wired (the M2
+ // chain above); the operational tracking store is SITE-ONLY (registered
+ // by ScadaLink.SiteRuntime). On a Central composition root the tracking
+ // store has no registration, so the factory resolves it with GetService
+ // (returning null) — the forwarder degrades to "audit-only" emission,
+ // mirroring the lazy IAuditWriter chain established in M2.
+ services.AddSingleton(sp =>
+ new CachedCallTelemetryForwarder(
+ sp.GetRequiredService(),
+ sp.GetService(),
+ sp.GetRequiredService>()));
+
+ // M3 Bundle F: bridge the store-and-forward retry-loop observer hook
+ // to the cached-call forwarder so per-attempt + terminal telemetry
+ // emitted from the S&F retry sweep lands on the same SQLite hot-path
+ // as the script-thread CachedSubmit row. Registered as a singleton
+ // and also bound to ICachedCallLifecycleObserver so AddStoreAndForward
+ // can resolve it through DI (Bundle F StoreAndForward wiring change).
+ services.AddSingleton();
+ services.AddSingleton(
+ sp => sp.GetRequiredService());
+
return services;
}
diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs
new file mode 100644
index 0000000..cc6a975
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs
@@ -0,0 +1,202 @@
+using Microsoft.Extensions.Logging;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Interfaces.Services;
+using ScadaLink.Commons.Messages.Integration;
+using ScadaLink.Commons.Types;
+using ScadaLink.Commons.Types.Enums;
+
+namespace ScadaLink.AuditLog.Site.Telemetry;
+
+///
+/// Audit Log #23 (M3 Bundle E — Tasks E4/E5): translates per-attempt
+/// notifications from the store-and-forward retry loop into one (or two)
+/// packets and pushes them through
+/// .
+///
+///
+///
+/// The S&F loop's reports a
+/// single coarse outcome per attempt; the audit pipeline however models the
+/// lifecycle as TWO rows on terminal outcomes — an Attempted
+/// ( / )
+/// row capturing the per-attempt mechanics, plus a
+/// row marking the terminal state for downstream consumers. The bridge fans
+/// out per outcome:
+///
+///
+/// - TransientFailure -> one Attempted(Failed) row.
+/// - Delivered -> Attempted(Delivered) + CachedResolve(Delivered).
+/// - PermanentFailure -> Attempted(Failed) + CachedResolve(Parked).
+/// - ParkedMaxRetries -> Attempted(Failed) + CachedResolve(Parked).
+///
+///
+/// Best-effort emission (alog.md §7): the bridge itself never throws;
+/// the underlying forwarder swallows + logs its own failures.
+///
+///
+public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
+{
+ private readonly ICachedCallTelemetryForwarder _forwarder;
+ private readonly ILogger _logger;
+
+ public CachedCallLifecycleBridge(
+ ICachedCallTelemetryForwarder forwarder,
+ ILogger logger)
+ {
+ _forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ public async Task OnAttemptCompletedAsync(
+ CachedCallAttemptContext context, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ try
+ {
+ await EmitAttemptedAsync(context, ct).ConfigureAwait(false);
+
+ if (IsTerminal(context.Outcome))
+ {
+ await EmitResolveAsync(context, ct).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ // Defensive — both EmitX paths call the forwarder which is itself
+ // best-effort. A throw here is unexpected, but the alog.md §7
+ // contract requires we never propagate.
+ _logger.LogWarning(ex,
+ "CachedCallLifecycleBridge: unexpected throw for {TrackedOperationId} (Outcome {Outcome})",
+ context.TrackedOperationId, context.Outcome);
+ }
+ }
+
+ private async Task EmitAttemptedAsync(CachedCallAttemptContext context, CancellationToken ct)
+ {
+ // Per-attempt row: kind discriminates channel; status is always
+ // Attempted regardless of outcome (success vs. failure is captured
+ // by the companion HttpStatus / ErrorMessage fields, NOT by flipping
+ // the status — CachedResolve carries the terminal Status). Per the
+ // M3 brief and alog.md §4.
+ var kind = ChannelToAttemptKind(context.Channel);
+ var status = AuditStatus.Attempted;
+
+ var packet = BuildPacket(
+ context,
+ kind: kind,
+ status: status,
+ // Operational status mirror — for the per-attempt row the
+ // operational state is the running status; the bridge always
+ // writes "Attempted" so reconciliation can't roll back.
+ operationalStatus: "Attempted",
+ terminalAtUtc: null,
+ lastError: context.LastError,
+ httpStatus: context.HttpStatus);
+
+ await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
+ }
+
+ private async Task EmitResolveAsync(CachedCallAttemptContext context, CancellationToken ct)
+ {
+ var (auditStatus, operationalStatus) = TerminalOutcomeToStatuses(context.Outcome);
+
+ var packet = BuildPacket(
+ context,
+ kind: AuditKind.CachedResolve,
+ status: auditStatus,
+ operationalStatus: operationalStatus,
+ terminalAtUtc: context.OccurredAtUtc,
+ lastError: context.LastError,
+ httpStatus: context.HttpStatus);
+
+ await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
+ }
+
+ private static CachedCallTelemetry BuildPacket(
+ CachedCallAttemptContext context,
+ AuditKind kind,
+ AuditStatus status,
+ string operationalStatus,
+ DateTime? terminalAtUtc,
+ string? lastError,
+ int? httpStatus)
+ {
+ var channel = ChannelStringToEnum(context.Channel);
+
+ return new CachedCallTelemetry(
+ Audit: new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
+ Channel = channel,
+ Kind = kind,
+ CorrelationId = context.TrackedOperationId.Value,
+ SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
+ SourceInstanceId = context.SourceInstanceId,
+ SourceScript = null, // Not threaded through S&F; left null on retry-loop rows.
+ Target = context.Target,
+ Status = status,
+ HttpStatus = httpStatus,
+ DurationMs = context.DurationMs,
+ ErrorMessage = lastError,
+ ForwardState = AuditForwardState.Pending,
+ },
+ Operational: new SiteCallOperational(
+ TrackedOperationId: context.TrackedOperationId,
+ Channel: context.Channel,
+ Target: context.Target,
+ SourceSite: context.SourceSite,
+ Status: operationalStatus,
+ RetryCount: context.RetryCount,
+ LastError: lastError,
+ HttpStatus: httpStatus,
+ CreatedAtUtc: DateTime.SpecifyKind(context.CreatedAtUtc, DateTimeKind.Utc),
+ UpdatedAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
+ TerminalAtUtc: terminalAtUtc is null
+ ? null
+ : DateTime.SpecifyKind(terminalAtUtc.Value, DateTimeKind.Utc)));
+ }
+
+ private static AuditKind ChannelToAttemptKind(string channel) => channel switch
+ {
+ "ApiOutbound" => AuditKind.ApiCallCached,
+ "DbOutbound" => AuditKind.DbWriteCached,
+ // Defensive default — the S&F observer is filtered to cached-call
+ // categories so this branch shouldn't fire in practice.
+ _ => AuditKind.ApiCallCached,
+ };
+
+ private static AuditChannel ChannelStringToEnum(string channel) => channel switch
+ {
+ "ApiOutbound" => AuditChannel.ApiOutbound,
+ "DbOutbound" => AuditChannel.DbOutbound,
+ _ => AuditChannel.ApiOutbound,
+ };
+
+ private static (AuditStatus auditStatus, string operationalStatus) TerminalOutcomeToStatuses(
+ CachedCallAttemptOutcome outcome) => outcome switch
+ {
+ CachedCallAttemptOutcome.Delivered =>
+ (AuditStatus.Delivered, "Delivered"),
+ CachedCallAttemptOutcome.PermanentFailure =>
+ (AuditStatus.Parked, "Parked"),
+ CachedCallAttemptOutcome.ParkedMaxRetries =>
+ (AuditStatus.Parked, "Parked"),
+ // TransientFailure isn't terminal — see IsTerminal — but the switch
+ // is exhaustive so we route it through Failed for safety.
+ CachedCallAttemptOutcome.TransientFailure =>
+ (AuditStatus.Failed, "Failed"),
+ _ => (AuditStatus.Failed, "Failed"),
+ };
+
+ private static bool IsTerminal(CachedCallAttemptOutcome outcome) => outcome switch
+ {
+ CachedCallAttemptOutcome.Delivered => true,
+ CachedCallAttemptOutcome.PermanentFailure => true,
+ CachedCallAttemptOutcome.ParkedMaxRetries => true,
+ CachedCallAttemptOutcome.TransientFailure => false,
+ _ => false,
+ };
+}
diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs
new file mode 100644
index 0000000..7f45453
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs
@@ -0,0 +1,179 @@
+using Microsoft.Extensions.Logging;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Interfaces;
+using ScadaLink.Commons.Interfaces.Services;
+using ScadaLink.Commons.Messages.Integration;
+using ScadaLink.Commons.Types;
+using ScadaLink.Commons.Types.Enums;
+
+namespace ScadaLink.AuditLog.Site.Telemetry;
+
+///
+/// Site-side dual emitter for cached-call lifecycle telemetry (Audit Log #23 /
+/// M3). Sister to : where the M2 actor
+/// drains audit-only events, this forwarder takes a combined
+/// packet and fans it out to the two
+/// site-local stores in a single call:
+///
+/// - The row is written via
+/// (the site FallbackAuditWriter +
+/// SqliteAuditWriter chain established in M2).
+/// - The operational half
+/// updates the site-local OperationTracking SQLite store via
+/// , with the per-lifecycle method
+/// (Enqueue / Attempt / Terminal) selected from the
+/// audit row's .
+///
+///
+///
+///
+/// Best-effort contract (alog.md §7): a thrown writer OR a thrown
+/// tracking store must never propagate to the calling script. Both emission
+/// halves are wrapped in independent try/catch blocks so a SQLite outage on
+/// one side cannot starve the other — the failure is logged and the call
+/// returns normally.
+///
+///
+/// Wire push deferred to M6. M3 keeps this forwarder synchronous
+/// against the local stores: there is no site→central gRPC channel yet, so
+/// the RPC
+/// is registered on the interface (Bundle E1) but the production binding
+/// remains NoOpSiteStreamAuditClient. Once M6 wires a real client the
+/// drain pattern from SiteAuditTelemetryActor can be reused — the
+/// AuditEvent rows already live in SQLite tagged
+/// , so a single drain loop sweeps
+/// both M2 and M3 emissions.
+///
+///
+public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
+{
+ private readonly IAuditWriter _auditWriter;
+ private readonly IOperationTrackingStore? _trackingStore;
+ private readonly ILogger _logger;
+
+ ///
+ /// Construct the forwarder. is optional —
+ /// when null only the audit half of the packet is emitted, which matches
+ /// the M3 Bundle F composition-root contract on Central nodes: the
+ /// AuditLog DI surface registers the forwarder unconditionally (mirroring
+ /// the IAuditWriter chain) but the site-only tracking store has no central
+ /// registration. Production site nodes wire both — the central lazy
+ /// resolution is a no-op path kept symmetric with the M2 writer chain.
+ ///
+ public CachedCallTelemetryForwarder(
+ IAuditWriter auditWriter,
+ IOperationTrackingStore? trackingStore,
+ ILogger logger)
+ {
+ _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _trackingStore = trackingStore;
+ }
+
+ ///
+ /// Fan out one combined-telemetry packet to the audit writer and the
+ /// tracking store. Returns once both halves have been attempted (success
+ /// OR logged failure). NEVER throws — exceptions are caught per-half and
+ /// logged at warning level so the calling script's outbound action is not
+ /// disturbed.
+ ///
+ public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(telemetry);
+
+ // Independent try/catch — a thrown audit writer must not prevent the
+ // tracking-store update from running (and vice-versa). Both halves
+ // are best-effort.
+ await TryEmitAuditAsync(telemetry, ct).ConfigureAwait(false);
+ await TryEmitTrackingAsync(telemetry, ct).ConfigureAwait(false);
+ }
+
+ private async Task TryEmitAuditAsync(CachedCallTelemetry telemetry, CancellationToken ct)
+ {
+ try
+ {
+ await _auditWriter.WriteAsync(telemetry.Audit, ct).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // alog.md §7 best-effort contract — log and swallow. The audit
+ // pipeline's own retry/recovery (RingBufferFallback in the
+ // FallbackAuditWriter) handles transient writer failures upstream;
+ // a throw bubbling up here means the writer's own swallow contract
+ // failed, which is itself best-effort-handled.
+ _logger.LogWarning(ex,
+ "CachedCallTelemetryForwarder: audit emission threw for EventId {EventId} (Kind {Kind}, Status {Status})",
+ telemetry.Audit.EventId, telemetry.Audit.Kind, telemetry.Audit.Status);
+ }
+ }
+
+ private async Task TryEmitTrackingAsync(CachedCallTelemetry telemetry, CancellationToken ct)
+ {
+ if (_trackingStore is null)
+ {
+ // No site-local tracking store wired — Central composition root or
+ // an integration-test host that skipped AddSiteRuntime. Emitting
+ // through the audit half is still meaningful; the tracking half
+ // is a no-op rather than an error.
+ return;
+ }
+
+ try
+ {
+ switch (telemetry.Audit.Kind)
+ {
+ case AuditKind.CachedSubmit:
+ // Enqueue — insert-if-not-exists with the operational
+ // channel as the kind discriminator. RetryCount is fixed
+ // at 0 by the tracking store's INSERT contract.
+ await _trackingStore.RecordEnqueueAsync(
+ telemetry.Operational.TrackedOperationId,
+ telemetry.Operational.Channel,
+ telemetry.Operational.Target,
+ telemetry.Audit.SourceInstanceId,
+ telemetry.Audit.SourceScript,
+ ct).ConfigureAwait(false);
+ break;
+
+ case AuditKind.ApiCallCached:
+ case AuditKind.DbWriteCached:
+ // Attempt — advance retry counter + last-error/HTTP-status.
+ // Terminal rows are guarded by the store's WHERE clause.
+ await _trackingStore.RecordAttemptAsync(
+ telemetry.Operational.TrackedOperationId,
+ telemetry.Operational.Status,
+ telemetry.Operational.RetryCount,
+ telemetry.Operational.LastError,
+ telemetry.Operational.HttpStatus,
+ ct).ConfigureAwait(false);
+ break;
+
+ case AuditKind.CachedResolve:
+ // Terminal — first-write-wins on the resolve flip.
+ await _trackingStore.RecordTerminalAsync(
+ telemetry.Operational.TrackedOperationId,
+ telemetry.Operational.Status,
+ telemetry.Operational.LastError,
+ telemetry.Operational.HttpStatus,
+ ct).ConfigureAwait(false);
+ break;
+
+ default:
+ // Defensive — only the four cached-lifecycle kinds are
+ // expected on this path. Anything else is logged so a
+ // mis-routed packet is visible but never crashes the
+ // forwarder.
+ _logger.LogWarning(
+ "CachedCallTelemetryForwarder: unexpected audit kind {Kind} on tracking emission for EventId {EventId}",
+ telemetry.Audit.Kind, telemetry.Audit.EventId);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "CachedCallTelemetryForwarder: tracking-store emission threw for TrackedOperationId {Id} (Status {Status})",
+ telemetry.Operational.TrackedOperationId, telemetry.Operational.Status);
+ }
+ }
+}
diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs b/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs
index c25b05a..6314bba 100644
--- a/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs
+++ b/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs
@@ -20,4 +20,23 @@ public interface ISiteStreamAuditClient
/// in the site SQLite queue.
///
Task IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct);
+
+ ///
+ /// Pushes the combined (Audit Log #23 / M3)
+ /// to the central IngestCachedTelemetry RPC. Each packet carries both
+ /// the audit row and the operational SiteCalls upsert; central writes
+ /// both in a single MS SQL transaction. Returns the same
+ /// shape as so
+ /// the M3 site-side forwarder can flip the underlying audit rows to
+ ///
+ /// once central has acknowledged them.
+ ///
+ ///
+ /// The production gRPC-backed implementation lands in M6 (no site→central
+ /// gRPC channel exists today); until then the default
+ /// binding returns an empty ack and
+ /// integration tests substitute a direct-actor client that routes the batch
+ /// straight into the in-process AuditLogIngestActor.
+ ///
+ Task IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct);
}
diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs b/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs
index b1a0190..b83a215 100644
--- a/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs
+++ b/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs
@@ -38,4 +38,16 @@ public sealed class NoOpSiteStreamAuditClient : ISiteStreamAuditClient
// Pending until M6's real client (or a Bundle H test stub) takes over.
return Task.FromResult(EmptyAck);
}
+
+ ///
+ public Task IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(batch);
+ // Empty ack — same rationale as IngestAuditEventsAsync. The M3
+ // CachedCallTelemetryForwarder still writes the audit + tracking rows to
+ // the site SQLite stores authoritatively; central-side state only
+ // materialises once M6's real gRPC client (or a Bundle G test stub) is
+ // wired in.
+ return Task.FromResult(EmptyAck);
+ }
}
diff --git a/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs b/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs
new file mode 100644
index 0000000..f83f3ab
--- /dev/null
+++ b/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs
@@ -0,0 +1,60 @@
+using ScadaLink.Commons.Types;
+
+namespace ScadaLink.Commons.Entities.Audit;
+
+///
+/// Central operational state row for a cached call (Site Call Audit #22, Audit Log #23 M3).
+/// One row per in the SiteCalls table — append-once
+/// then monotonic status update. Status transitions are forward-only
+/// (Submitted → Forwarded → Attempted → Delivered|Failed|Parked|Discarded); an
+/// out-of-order or duplicate upsert is a silent no-op so duplicate gRPC packets and
+/// reconciliation pulls can both feed the same writer without rolling state back.
+///
+///
+/// Sites remain the source of truth — this row is the eventually-consistent mirror the
+/// Central UI's Site Calls page reads. Unlike the partitioned AuditLog table this
+/// entity backs operational (mutable) state on a standard non-partitioned table on
+/// [PRIMARY]; no DB-role restriction applies.
+///
+public sealed record SiteCall
+{
+ /// Strong-typed idempotency key shared with every audit row for the operation.
+ public required TrackedOperationId TrackedOperationId { get; init; }
+
+ /// Trust-boundary channel — "ApiOutbound" or "DbOutbound".
+ public required string Channel { get; init; }
+
+ /// Human-readable target (e.g. "ERP.GetOrder").
+ public required string Target { get; init; }
+
+ /// Site id that submitted the cached call.
+ public required string SourceSite { get; init; }
+
+ ///
+ /// Lifecycle status — string form of
+ /// . Monotonic: later rank
+ /// wins, earlier rank is a no-op.
+ ///
+ public required string Status { get; init; }
+
+ /// Number of dispatch attempts so far; 0 prior to first attempt.
+ public required int RetryCount { get; init; }
+
+ /// Most recent error message; null when no failures have occurred.
+ public string? LastError { get; init; }
+
+ /// Most recent HTTP status code (API calls only); null otherwise.
+ public int? HttpStatus { get; init; }
+
+ /// UTC timestamp the cached call was first submitted at the site.
+ public required DateTime CreatedAtUtc { get; init; }
+
+ /// UTC timestamp of the latest status mutation at the site.
+ public required DateTime UpdatedAtUtc { get; init; }
+
+ /// UTC timestamp the row reached a terminal status; null while still active.
+ public DateTime? TerminalAtUtc { get; init; }
+
+ /// UTC timestamp central ingested (or last refreshed) this row.
+ public required DateTime IngestedAtUtc { get; init; }
+}
diff --git a/src/ScadaLink.Commons/Interfaces/IOperationTrackingStore.cs b/src/ScadaLink.Commons/Interfaces/IOperationTrackingStore.cs
new file mode 100644
index 0000000..add0a8c
--- /dev/null
+++ b/src/ScadaLink.Commons/Interfaces/IOperationTrackingStore.cs
@@ -0,0 +1,87 @@
+using ScadaLink.Commons.Types;
+
+namespace ScadaLink.Commons.Interfaces;
+
+///
+/// Site-local source of truth for cached-operation tracking
+/// (ExternalSystem.CachedCall / Database.CachedWrite) — alongside the
+/// Store-and-Forward buffer, this is the row that Tracking.Status(id)
+/// reads (Audit Log #23 / M3). One row per ;
+/// terminal rows are purged after a configurable retention window
+/// (default 7 days).
+///
+///
+///
+/// The store is intentionally a thin write-API on top of SQLite — not a
+/// dispatcher. Status transitions follow
+/// Submitted → Retrying → Delivered / Parked / Failed / Discarded; rows
+/// in a terminal state never roll back. Implementations must:
+///
+/// - is insert-if-not-exists
+/// (caller-supplied id is the idempotency key — duplicate enqueues are no-ops).
+/// - only updates non-terminal rows.
+/// - only flips a non-terminal row to terminal.
+/// - deletes terminal rows whose
+/// TerminalAtUtc is strictly older than the supplied threshold.
+///
+///
+///
+public interface IOperationTrackingStore
+{
+ ///
+ /// Insert a new tracking row in Submitted state with RetryCount = 0.
+ /// Idempotent — a duplicate id is silently ignored (the existing row is left
+ /// untouched), matching the at-least-once semantics of the calling site
+ /// store-and-forward path.
+ ///
+ Task RecordEnqueueAsync(
+ TrackedOperationId id,
+ string kind,
+ string? targetSummary,
+ string? sourceInstanceId,
+ string? sourceScript,
+ CancellationToken ct = default);
+
+ ///
+ /// Advance an in-flight tracking row's status, retry counter, and most-
+ /// recent error/HTTP-status. Terminal rows (
+ /// already applied) are NOT mutated — the operation has reached its final
+ /// outcome and any late-arriving attempt telemetry is dropped on the floor.
+ ///
+ Task RecordAttemptAsync(
+ TrackedOperationId id,
+ string status,
+ int retryCount,
+ string? lastError,
+ int? httpStatus,
+ CancellationToken ct = default);
+
+ ///
+ /// Flip a non-terminal tracking row to terminal — sets
+ /// TerminalAtUtc = now and writes the final status / error. A row
+ /// already in terminal state is left untouched (first-write-wins).
+ ///
+ Task RecordTerminalAsync(
+ TrackedOperationId id,
+ string status,
+ string? lastError,
+ int? httpStatus,
+ CancellationToken ct = default);
+
+ ///
+ /// Return the latest snapshot for the supplied id, or null when no
+ /// tracking row exists (purged or never recorded).
+ ///
+ Task GetStatusAsync(
+ TrackedOperationId id,
+ CancellationToken ct = default);
+
+ ///
+ /// Delete terminal rows whose TerminalAtUtc is strictly older than
+ /// . Non-terminal rows are kept regardless
+ /// of age (the operation is still in flight).
+ ///
+ Task PurgeTerminalAsync(
+ DateTime olderThanUtc,
+ CancellationToken ct = default);
+}
diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs
new file mode 100644
index 0000000..7bb3790
--- /dev/null
+++ b/src/ScadaLink.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs
@@ -0,0 +1,66 @@
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types;
+using ScadaLink.Commons.Types.Audit;
+
+namespace ScadaLink.Commons.Interfaces.Repositories;
+
+///
+/// Operational-state data access for the central SiteCalls table
+/// (Site Call Audit #22, Audit Log #23 M3 Bundle B). One row per
+/// ; sites remain the source of truth and this
+/// table is an eventually-consistent mirror fed by best-effort gRPC telemetry
+/// plus periodic reconciliation pulls.
+///
+///
+///
+/// Unlike the partitioned append-only AuditLog (M1), this table holds
+/// mutable operational state. is insert-if-not-exists
+/// then monotonic update — a status update with rank less than or equal to the
+/// stored status is a silent no-op so out-of-order telemetry, duplicate gRPC
+/// packets, and reconciliation pulls can all feed the same writer without
+/// rolling state backward.
+///
+///
+/// Status rank for monotonic comparison (lower wins): Submitted=0,
+/// Forwarded=1, Attempted=2, Skipped=2, Delivered=3, Failed=3, Parked=3,
+/// Discarded=3. Terminal statuses share rank 3 and are mutually exclusive
+/// — an attempt to upsert e.g. Delivered over an existing Parked
+/// row is a no-op.
+///
+///
+public interface ISiteCallAuditRepository
+{
+ ///
+ /// Inserts if no row with the same
+ /// exists; otherwise updates the
+ /// existing row IF AND ONLY IF the incoming status' rank strictly exceeds
+ /// the stored status' rank. Out-of-order / duplicate updates are silently
+ /// dropped (monotonic forward-only progression).
+ ///
+ Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default);
+
+ ///
+ /// Returns the row for the given id, or null if none exists.
+ ///
+ Task GetAsync(TrackedOperationId id, CancellationToken ct = default);
+
+ ///
+ /// Returns up to rows matching
+ /// , ordered by (CreatedAtUtc DESC,
+ /// TrackedOperationId DESC). Use keyset paging via
+ /// +
+ /// to fetch subsequent pages.
+ ///
+ Task> QueryAsync(
+ SiteCallQueryFilter filter,
+ SiteCallPaging paging,
+ CancellationToken ct = default);
+
+ ///
+ /// Deletes terminal rows whose is
+ /// strictly older than . Non-terminal rows
+ /// (TerminalAtUtc IS NULL) are NEVER purged. Returns the number of rows
+ /// deleted.
+ ///
+ Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default);
+}
diff --git a/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs b/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs
new file mode 100644
index 0000000..8e34cb4
--- /dev/null
+++ b/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs
@@ -0,0 +1,93 @@
+using ScadaLink.Commons.Types;
+
+namespace ScadaLink.Commons.Interfaces.Services;
+
+///
+/// Audit Log #23 (M3 Bundle E — Tasks E4/E5): site-side hook the
+/// store-and-forward retry loop invokes after every cached-call attempt and
+/// at terminal-state transitions, so the audit pipeline can emit
+/// ApiCallCached/DbWriteCached per-attempt rows and the
+/// CachedResolve terminal row under the original
+/// .
+///
+///
+///
+/// The interface deliberately uses
+/// rather than so the
+/// S&F project does not need to depend on the audit vocabulary — the
+/// bridge living in ScadaLink.AuditLog maps the outcome to the right
+/// audit kind + status when materialising the CachedCallTelemetry
+/// packet.
+///
+///
+/// Best-effort contract (alog.md §7): implementations MUST swallow
+/// internal failures rather than propagating to the S&F service — a
+/// thrown observer must not be misclassified as a transient delivery
+/// failure and must not corrupt the retry-count bookkeeping.
+///
+///
+public interface ICachedCallLifecycleObserver
+{
+ ///
+ /// Called by the store-and-forward retry loop after every cached-call
+ /// delivery attempt. Receives the message's TrackedOperationId-bearing id,
+ /// the per-category channel discriminator, retry-count + last-error
+ /// context, and whether the outcome reached a terminal state.
+ ///
+ Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default);
+}
+
+///
+/// Per-attempt context handed to .
+///
+///
+/// Tracking id parsed from the underlying StoreAndForwardMessage.Id.
+///
+///
+/// Trust-boundary channel string — "ApiOutbound" for ExternalSystem
+/// cached calls, "DbOutbound" for cached DB writes.
+///
+/// Human-readable target (system name / DB connection).
+/// Site id that submitted the cached call.
+/// Per-attempt outcome.
+/// Number of retries performed so far (S&F bookkeeping).
+/// Most recent error message (null on success).
+/// Most recent HTTP status (null when not applicable).
+/// When the underlying S&F message was first enqueued.
+/// When this attempt completed.
+/// Duration of the attempt in milliseconds (null when not measured).
+/// Originating instance, when known.
+public sealed record CachedCallAttemptContext(
+ TrackedOperationId TrackedOperationId,
+ string Channel,
+ string Target,
+ string SourceSite,
+ CachedCallAttemptOutcome Outcome,
+ int RetryCount,
+ string? LastError,
+ int? HttpStatus,
+ DateTime CreatedAtUtc,
+ DateTime OccurredAtUtc,
+ int? DurationMs,
+ string? SourceInstanceId);
+
+///
+/// Coarse outcome of one cached-call delivery attempt, observed from inside
+/// the store-and-forward retry loop. The audit bridge maps this to the
+/// ApiCallCached/DbWriteCached Attempted row and, when terminal,
+/// the corresponding CachedResolve row.
+///
+public enum CachedCallAttemptOutcome
+{
+ /// Attempt delivered successfully — terminal Delivered state.
+ Delivered,
+
+ /// Attempt failed transiently; another retry will follow.
+ TransientFailure,
+
+ /// Attempt returned permanent failure — terminal Parked state (S&F semantics).
+ PermanentFailure,
+
+ /// Retry budget exhausted — terminal Parked state.
+ ParkedMaxRetries,
+}
diff --git a/src/ScadaLink.Commons/Interfaces/Services/ICachedCallTelemetryForwarder.cs b/src/ScadaLink.Commons/Interfaces/Services/ICachedCallTelemetryForwarder.cs
new file mode 100644
index 0000000..60ff6bb
--- /dev/null
+++ b/src/ScadaLink.Commons/Interfaces/Services/ICachedCallTelemetryForwarder.cs
@@ -0,0 +1,34 @@
+using ScadaLink.Commons.Messages.Integration;
+
+namespace ScadaLink.Commons.Interfaces.Services;
+
+///
+/// Site-side fan-out abstraction for cached-call lifecycle telemetry
+/// (Audit Log #23 / M3). One packet carries
+/// both an audit row and an operational SiteCalls upsert; the
+/// implementation routes the audit half through
+/// and the operational half through the site-local tracking SQLite store.
+///
+///
+///
+/// Defined in Commons so the script runtime (and the StoreAndForward retry
+/// loop, Bundle E4) can take a dependency on the abstraction rather than on
+/// the concrete forwarder living inside ScadaLink.AuditLog — the
+/// existing dependency arrow runs from SiteRuntime to Commons, not to
+/// AuditLog.
+///
+///
+/// Best-effort contract (alog.md §7): implementations MUST swallow
+/// internal failures rather than propagating to the calling script.
+///
+///
+public interface ICachedCallTelemetryForwarder
+{
+ ///
+ /// Fan one combined-telemetry packet out to the audit writer and the
+ /// tracking store. Best-effort — failures on either half are logged and
+ /// swallowed; the returned Task completes when both halves have been
+ /// attempted.
+ ///
+ Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default);
+}
diff --git a/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs b/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs
index f21c8f6..60defe9 100644
--- a/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs
+++ b/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs
@@ -1,4 +1,5 @@
using System.Data.Common;
+using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Interfaces.Services;
@@ -20,10 +21,19 @@ public interface IDatabaseGateway
///
/// Submits a SQL write to the store-and-forward engine for reliable delivery.
///
+ ///
+ /// Audit Log #23 (M3): caller-supplied tracking id used as the
+ /// store-and-forward message id so the S&F retry loop can read it
+ /// back via StoreAndForwardMessage.Id and emit per-attempt /
+ /// terminal cached-write telemetry under the same id. Defaults to
+ /// null — when omitted the S&F engine mints a fresh GUID and no
+ /// M3 telemetry is correlated (pre-M3 caller behaviour).
+ ///
Task CachedWriteAsync(
string connectionName,
string sql,
IReadOnlyDictionary? parameters = null,
string? originInstanceName = null,
- CancellationToken cancellationToken = default);
+ CancellationToken cancellationToken = default,
+ TrackedOperationId? trackedOperationId = null);
}
diff --git a/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs b/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs
index c875ebf..627a81d 100644
--- a/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs
+++ b/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs
@@ -21,12 +21,22 @@ public interface IExternalSystemClient
/// Attempt immediate delivery; on transient failure, hand to S&F engine.
/// Permanent failures returned to caller.
///
+ ///
+ /// Audit Log #23 (M3): caller-supplied tracking id used as the
+ /// store-and-forward message id so the S&F retry loop can read it
+ /// back via StoreAndForwardMessage.Id and emit per-attempt /
+ /// terminal cached-call telemetry under the same id. Defaults to
+ /// null — when omitted the S&F engine mints a fresh GUID and no
+ /// M3 telemetry is correlated (the legacy behaviour pre-M3 callers rely
+ /// on).
+ ///
Task CachedCallAsync(
string systemName,
string methodName,
IReadOnlyDictionary? parameters = null,
string? originInstanceName = null,
- CancellationToken cancellationToken = default);
+ CancellationToken cancellationToken = default,
+ TrackedOperationId? trackedOperationId = null);
}
///
diff --git a/src/ScadaLink.Commons/Messages/Audit/IngestCachedTelemetryCommand.cs b/src/ScadaLink.Commons/Messages/Audit/IngestCachedTelemetryCommand.cs
new file mode 100644
index 0000000..e7c63a1
--- /dev/null
+++ b/src/ScadaLink.Commons/Messages/Audit/IngestCachedTelemetryCommand.cs
@@ -0,0 +1,30 @@
+using ScadaLink.Commons.Entities.Audit;
+
+namespace ScadaLink.Commons.Messages.Audit;
+
+///
+/// Akka message sent to the central AuditLogIngestActor (Audit Log #23 M3
+/// Bundle D dual-write transaction) carrying a batch of combined audit +
+/// site-call telemetry packets decoded by the SiteStreamGrpcServer from a
+/// site's IngestCachedTelemetry gRPC RPC. For each entry the actor writes
+/// the row AND the upsert inside
+/// a single MS SQL transaction — both succeed or both roll back, so the audit
+/// and operational mirrors never drift mid-row.
+///
+///
+/// Lives in ScadaLink.Commons for the same reason as
+/// IngestAuditEventsCommand: the gRPC server in
+/// ScadaLink.Communication constructs it and ScadaLink.AuditLog
+/// already references Communication. Putting the message in Commons avoids a
+/// project-reference cycle.
+///
+public sealed record IngestCachedTelemetryCommand(IReadOnlyList Entries);
+
+///
+/// One lifecycle event of a cached call: the to insert
+/// (idempotent on ) plus the
+/// to upsert (monotonic on
+/// ). The two rows are paired so the
+/// central dual-write transaction can commit them atomically.
+///
+public sealed record CachedTelemetryEntry(AuditEvent Audit, SiteCall SiteCall);
diff --git a/src/ScadaLink.Commons/Messages/Audit/IngestCachedTelemetryReply.cs b/src/ScadaLink.Commons/Messages/Audit/IngestCachedTelemetryReply.cs
new file mode 100644
index 0000000..1de259d
--- /dev/null
+++ b/src/ScadaLink.Commons/Messages/Audit/IngestCachedTelemetryReply.cs
@@ -0,0 +1,10 @@
+namespace ScadaLink.Commons.Messages.Audit;
+
+///
+/// Reply from the central AuditLogIngestActor for an
+/// .
+/// lists every entry whose dual-write transaction (AuditLog INSERT + SiteCalls
+/// UPSERT) committed; entries whose transaction rolled back are absent so the
+/// site can leave the row Pending and retry on the next drain.
+///
+public sealed record IngestCachedTelemetryReply(IReadOnlyList AcceptedEventIds);
diff --git a/src/ScadaLink.Commons/Messages/Audit/UpsertSiteCallCommand.cs b/src/ScadaLink.Commons/Messages/Audit/UpsertSiteCallCommand.cs
new file mode 100644
index 0000000..ec0da24
--- /dev/null
+++ b/src/ScadaLink.Commons/Messages/Audit/UpsertSiteCallCommand.cs
@@ -0,0 +1,19 @@
+using ScadaLink.Commons.Entities.Audit;
+
+namespace ScadaLink.Commons.Messages.Audit;
+
+///
+/// Akka message sent to the central SiteCallAuditActor (Site Call Audit
+/// #22, Audit Log #23 M3 Bundle C) carrying one row to
+/// be persisted via ISiteCallAuditRepository.UpsertAsync. 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.
+///
+///
+/// Lives in ScadaLink.Commons rather than ScadaLink.SiteCallAudit
+/// so the gRPC server in ScadaLink.Communication 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).
+///
+public sealed record UpsertSiteCallCommand(SiteCall SiteCall);
diff --git a/src/ScadaLink.Commons/Messages/Audit/UpsertSiteCallReply.cs b/src/ScadaLink.Commons/Messages/Audit/UpsertSiteCallReply.cs
new file mode 100644
index 0000000..de31cec
--- /dev/null
+++ b/src/ScadaLink.Commons/Messages/Audit/UpsertSiteCallReply.cs
@@ -0,0 +1,14 @@
+using ScadaLink.Commons.Types;
+
+namespace ScadaLink.Commons.Messages.Audit;
+
+///
+/// Reply from the central SiteCallAuditActor for an
+/// . is true
+/// when the upsert reached the repository without throwing (including the
+/// monotonic-no-op case where the stored status' rank wins) and false
+/// 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).
+///
+public sealed record UpsertSiteCallReply(TrackedOperationId TrackedOperationId, bool Accepted);
diff --git a/src/ScadaLink.Commons/Messages/Integration/CachedCallTelemetry.cs b/src/ScadaLink.Commons/Messages/Integration/CachedCallTelemetry.cs
new file mode 100644
index 0000000..37532aa
--- /dev/null
+++ b/src/ScadaLink.Commons/Messages/Integration/CachedCallTelemetry.cs
@@ -0,0 +1,34 @@
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types;
+
+namespace ScadaLink.Commons.Messages.Integration;
+
+///
+/// Combined audit + operational telemetry packet for cached outbound calls
+/// (Audit Log #23 / M3). The site emits one packet per lifecycle event
+/// — Submit (Audit kind CachedSubmit), per-attempt
+/// ApiCallCached/DbWriteCached, terminal CachedResolve —
+/// and central writes the row plus the
+/// upsert in one MS SQL transaction. Two
+/// payload shapes ride on a single wire message so the same on-the-wire batch
+/// can carry mixed lifecycle events without the central dual-write needing a
+/// second RPC for the operational state.
+///
+///
+///
+/// Both inner records carry the same TrackedOperationId — the
+/// idempotency key end-to-end. The
+/// pattern (used by Audit Log #23 to thread cached-call rows together) is
+/// honoured by the site emitter; the packet itself is shape-only and makes no
+/// independent correlation guarantees.
+///
+///
+/// Additive-only per Commons REQ-COM-5a (M2 reviewer note) — this is a new
+/// message, not a rename of any existing M2 envelope.
+///
+///
+/// The Audit Log #23 row to insert at central.
+/// The SiteCalls upsert mirroring this lifecycle event.
+public sealed record CachedCallTelemetry(
+ AuditEvent Audit,
+ SiteCallOperational Operational);
diff --git a/src/ScadaLink.Commons/Types/Audit/SiteCallPaging.cs b/src/ScadaLink.Commons/Types/Audit/SiteCallPaging.cs
new file mode 100644
index 0000000..4a8f790
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/Audit/SiteCallPaging.cs
@@ -0,0 +1,15 @@
+namespace ScadaLink.Commons.Types.Audit;
+
+///
+/// Keyset paging cursor for
+/// .
+/// The repository orders by (CreatedAtUtc DESC, TrackedOperationId DESC) — newest
+/// calls first, with the strong-typed id breaking ties when two calls share an exact
+/// CreatedAtUtc. Callers pass the last row of the previous page back as
+/// + to fetch the next page.
+/// Both must be non-null together, or both null (first page).
+///
+public sealed record SiteCallPaging(
+ int PageSize,
+ DateTime? AfterCreatedAtUtc = null,
+ TrackedOperationId? AfterId = null);
diff --git a/src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs b/src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs
new file mode 100644
index 0000000..cf7e7d4
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs
@@ -0,0 +1,21 @@
+namespace ScadaLink.Commons.Types.Audit;
+
+///
+/// Filter predicate for .
+/// Any field left null means "do not constrain on that column". Time bounds
+/// are half-open in the spec sense — is inclusive and
+/// is inclusive of the upper bound; the repository SQL uses
+/// >= / <= respectively. All filter fields are AND-combined.
+///
+///
+/// Channel / Status / SourceSite / Target are matched as exact strings — the
+/// underlying columns are bounded ASCII (varchar) and the Central UI Site Calls
+/// page exposes them as drop-down filters, not free-text search.
+///
+public sealed record SiteCallQueryFilter(
+ string? Channel = null,
+ string? SourceSite = null,
+ string? Status = null,
+ string? Target = null,
+ DateTime? FromUtc = null,
+ DateTime? ToUtc = null);
diff --git a/src/ScadaLink.Commons/Types/SiteCallOperational.cs b/src/ScadaLink.Commons/Types/SiteCallOperational.cs
new file mode 100644
index 0000000..25d9414
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/SiteCallOperational.cs
@@ -0,0 +1,46 @@
+namespace ScadaLink.Commons.Types;
+
+///
+/// Operational state of one cached call as seen by the site, carried on the
+/// combined CachedCallTelemetry packet (Audit Log #23 / M3) and persisted
+/// at central as the SiteCalls row mirroring the call's status.
+///
+///
+///
+/// One row per at central; ingest is
+/// insert-if-not-exists then upsert-on-newer-status (monotonic — never rolls
+/// back). The site remains the source of truth — this record is the
+/// "eventually-consistent mirror" the central UI's Site Calls page reads.
+///
+///
+/// Idempotency key shared with the audit row.
+///
+/// Trust-boundary channel — "ApiOutbound" or "DbOutbound". String form
+/// (not the enum) so the
+/// record serialises identically across SQL / gRPC / JSON boundaries.
+///
+/// Human-readable target (e.g. "ERP.GetOrder").
+/// Site id that submitted the cached call.
+///
+/// Lifecycle status — string form of :
+/// Submitted, Retrying, Attempted, Delivered,
+/// Failed, Parked, Discarded.
+///
+/// Number of dispatch attempts so far; 0 prior to first attempt.
+/// Most recent error message; null when no failures have occurred.
+/// Most recent HTTP status code (API calls only); null otherwise.
+/// UTC timestamp the cached call was first submitted.
+/// UTC timestamp of the latest status mutation.
+/// UTC timestamp the row reached a terminal status; null while still active.
+public sealed record SiteCallOperational(
+ TrackedOperationId TrackedOperationId,
+ string Channel,
+ string Target,
+ string SourceSite,
+ string Status,
+ int RetryCount,
+ string? LastError,
+ int? HttpStatus,
+ DateTime CreatedAtUtc,
+ DateTime UpdatedAtUtc,
+ DateTime? TerminalAtUtc);
diff --git a/src/ScadaLink.Commons/Types/TrackedOperationId.cs b/src/ScadaLink.Commons/Types/TrackedOperationId.cs
new file mode 100644
index 0000000..3a6bb22
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/TrackedOperationId.cs
@@ -0,0 +1,53 @@
+namespace ScadaLink.Commons.Types;
+
+///
+/// Strongly-typed identifier for a cached outbound operation
+/// (ExternalSystem.CachedCall / Database.CachedWrite) — the unified
+/// tracking handle introduced by Audit Log #23 (M3). The same id is the
+/// idempotency key end-to-end: it is stamped on every AuditLog row
+/// produced for the operation's lifecycle (CachedSubmit → ApiCallCached /
+/// DbWriteCached × N attempts → CachedResolve) and is the PK on the central
+/// SiteCalls row that mirrors the operation's operational state.
+///
+///
+///
+/// The struct wraps a so it serialises identically to a
+/// 36-character "D"-format string anywhere the existing GUID conventions are
+/// used (gRPC strings, JSON, SQL TEXT columns). returns
+/// the lower-case 8-4-4-4-12 form unconditionally; never the brace- / parens-
+/// wrapped variants — central ingest parses with , which
+/// is format-tolerant but the wire shape is fixed for log readability.
+///
+///
+public readonly record struct TrackedOperationId(Guid Value)
+{
+ /// Mint a fresh id at the call site (script-thread safe).
+ public static TrackedOperationId New() => new(Guid.NewGuid());
+
+ ///
+ /// Parse a serialised id back into the strong type. Throws when the input
+ /// is not a valid GUID — callers crossing untrusted boundaries should use
+ /// instead.
+ ///
+ public static TrackedOperationId Parse(string s) => new(Guid.Parse(s));
+
+ ///
+ /// Attempt to parse a serialised id. Returns false for null, empty
+ /// or non-GUID input; is default on
+ /// failure.
+ ///
+ public static bool TryParse(string? s, out TrackedOperationId result)
+ {
+ if (Guid.TryParse(s, out var g))
+ {
+ result = new TrackedOperationId(g);
+ return true;
+ }
+
+ result = default;
+ return false;
+ }
+
+ ///
+ public override string ToString() => Value.ToString("D");
+}
diff --git a/src/ScadaLink.Commons/Types/TrackingStatusSnapshot.cs b/src/ScadaLink.Commons/Types/TrackingStatusSnapshot.cs
new file mode 100644
index 0000000..22136ff
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/TrackingStatusSnapshot.cs
@@ -0,0 +1,40 @@
+namespace ScadaLink.Commons.Types;
+
+///
+/// Site-local snapshot of a cached operation's tracking state, returned by the
+/// Tracking.Status(TrackedOperationId) script API (Audit Log #23 / M3).
+///
+/// Tracking handle returned by CachedCall/CachedWrite.
+///
+/// Operation category — "ApiCallCached" or "DbWriteCached" — mirroring
+/// the per-attempt vocabulary.
+///
+///
+/// Human-readable target (e.g. "ERP.GetOrder" or "WarehouseDb"); may be
+/// null for early-lifecycle rows recorded before the target was resolved.
+///
+///
+/// Lifecycle status — one of Submitted, Forwarded, Retrying,
+/// Attempted, Delivered, Failed, Parked, Discarded.
+///
+/// Number of attempts made; 0 prior to first dispatch.
+/// Most recent error message; null while non-terminal-and-no-failures.
+/// Most recent HTTP status code where applicable; null otherwise.
+/// UTC timestamp the tracking row was first recorded.
+/// UTC timestamp of the latest status mutation.
+/// UTC timestamp the row reached a terminal status; null while still active.
+/// Instance id that issued the cached call, when known.
+/// Script that issued the cached call, when known.
+public sealed record TrackingStatusSnapshot(
+ TrackedOperationId Id,
+ string Kind,
+ string? TargetSummary,
+ string Status,
+ int RetryCount,
+ string? LastError,
+ int? HttpStatus,
+ DateTime CreatedAtUtc,
+ DateTime UpdatedAtUtc,
+ DateTime? TerminalAtUtc,
+ string? SourceInstanceId,
+ string? SourceScript);
diff --git a/src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs b/src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs
index 71560c3..1da14ec 100644
--- a/src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs
+++ b/src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Messages.Audit;
+using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using GrpcStatus = Grpc.Core.Status;
@@ -298,9 +299,132 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
return ack;
}
+ ///
+ /// Audit Log (#23) M3 site→central combined-telemetry push RPC. Decodes a
+ /// batch of entries into matched
+ /// (AuditEvent, SiteCall) pairs, Asks the central AuditLogIngestActor
+ /// proxy to persist them in dual-write transactions, and echoes the
+ /// AuditEvent EventIds that committed back so the site can flip its local
+ /// rows to Forwarded.
+ ///
+ ///
+ /// Same wiring-incomplete fallback as : when
+ /// the actor proxy has not been set the RPC replies with an empty ack so
+ /// sites treat the outcome as transient and retry, never a hard fault.
+ ///
+ public override async Task IngestCachedTelemetry(
+ CachedTelemetryBatch request,
+ ServerCallContext context)
+ {
+ if (request.Packets.Count == 0)
+ {
+ return new IngestAck();
+ }
+
+ var actor = _auditIngestActor;
+ if (actor is null)
+ {
+ _logger.LogWarning(
+ "IngestCachedTelemetry received {Count} packets before SetAuditIngestActor was called; returning empty ack.",
+ request.Packets.Count);
+ return new IngestAck();
+ }
+
+ var entries = new List(request.Packets.Count);
+ foreach (var packet in request.Packets)
+ {
+ var auditEvent = MapAuditEventFromDto(packet.AuditEvent);
+ var siteCall = MapSiteCallFromDto(packet.Operational);
+ entries.Add(new CachedTelemetryEntry(auditEvent, siteCall));
+ }
+
+ var cmd = new IngestCachedTelemetryCommand(entries);
+ IngestCachedTelemetryReply reply;
+ try
+ {
+ reply = await actor.Ask(
+ cmd, AuditIngestAskTimeout, context.CancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "AuditLogIngestActor Ask failed for combined telemetry batch of {Count} packets; returning empty ack.",
+ request.Packets.Count);
+ return new IngestAck();
+ }
+
+ var ack = new IngestAck();
+ foreach (var id in reply.AcceptedEventIds)
+ {
+ ack.AcceptedEventIds.Add(id.ToString());
+ }
+ return ack;
+ }
+
private static string? NullIfEmpty(string? value) =>
string.IsNullOrEmpty(value) ? null : value;
+ ///
+ /// Inlined audit-event DTO→entity translation, kept in sync with the
+ /// handler above. Extracted to a private
+ /// helper so the M3 dual-write RPC can reuse it without duplicating yet
+ /// another copy. The shape still mirrors
+ /// AuditEventMapper.FromDto in ScadaLink.AuditLog.Telemetry;
+ /// the two must evolve together (the project-reference cycle that
+ /// prevents calling the AuditLog mapper directly is documented on
+ /// ).
+ ///
+ private static AuditEvent MapAuditEventFromDto(AuditEventDto dto) =>
+ new()
+ {
+ EventId = Guid.Parse(dto.EventId),
+ OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
+ IngestedAtUtc = null,
+ Channel = Enum.Parse(dto.Channel),
+ Kind = Enum.Parse(dto.Kind),
+ CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
+ SourceSiteId = NullIfEmpty(dto.SourceSiteId),
+ SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
+ SourceScript = NullIfEmpty(dto.SourceScript),
+ Actor = NullIfEmpty(dto.Actor),
+ Target = NullIfEmpty(dto.Target),
+ Status = Enum.Parse(dto.Status),
+ HttpStatus = dto.HttpStatus,
+ DurationMs = dto.DurationMs,
+ ErrorMessage = NullIfEmpty(dto.ErrorMessage),
+ ErrorDetail = NullIfEmpty(dto.ErrorDetail),
+ RequestSummary = NullIfEmpty(dto.RequestSummary),
+ ResponseSummary = NullIfEmpty(dto.ResponseSummary),
+ PayloadTruncated = dto.PayloadTruncated,
+ Extra = NullIfEmpty(dto.Extra),
+ ForwardState = null,
+ };
+
+ ///
+ /// Translates a into the persistence
+ /// entity. is stamped here as a
+ /// placeholder; the central ingest actor overwrites it inside the
+ /// dual-write transaction so the AuditLog and SiteCalls rows share one
+ /// instant.
+ ///
+ private static SiteCall MapSiteCallFromDto(SiteCallOperationalDto dto) => new()
+ {
+ TrackedOperationId = TrackedOperationId.Parse(dto.TrackedOperationId),
+ Channel = dto.Channel,
+ Target = dto.Target,
+ SourceSite = dto.SourceSite,
+ Status = dto.Status,
+ RetryCount = dto.RetryCount,
+ LastError = string.IsNullOrEmpty(dto.LastError) ? null : dto.LastError,
+ HttpStatus = dto.HttpStatus,
+ CreatedAtUtc = DateTime.SpecifyKind(dto.CreatedAtUtc.ToDateTime(), DateTimeKind.Utc),
+ UpdatedAtUtc = DateTime.SpecifyKind(dto.UpdatedAtUtc.ToDateTime(), DateTimeKind.Utc),
+ TerminalAtUtc = dto.TerminalAtUtc is null
+ ? null
+ : DateTime.SpecifyKind(dto.TerminalAtUtc.ToDateTime(), DateTimeKind.Utc),
+ IngestedAtUtc = DateTime.UtcNow, // overwritten by AuditLogIngestActor
+ };
+
///
/// Tracks a single active stream so cleanup only removes its own entry.
///
diff --git a/src/ScadaLink.Communication/Protos/sitestream.proto b/src/ScadaLink.Communication/Protos/sitestream.proto
index d01852f..43ffbe3 100644
--- a/src/ScadaLink.Communication/Protos/sitestream.proto
+++ b/src/ScadaLink.Communication/Protos/sitestream.proto
@@ -8,6 +8,7 @@ import "google/protobuf/wrappers.proto"; // Int32Value
service SiteStreamService {
rpc SubscribeInstance(InstanceStreamRequest) returns (stream SiteStreamEvent);
rpc IngestAuditEvents(AuditEventBatch) returns (IngestAck);
+ rpc IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck);
}
message InstanceStreamRequest {
@@ -93,3 +94,28 @@ message AuditEventDto {
message AuditEventBatch { repeated AuditEventDto events = 1; }
message IngestAck { repeated string accepted_event_ids = 1; }
+
+// Audit Log (#23) M3 cached-call combined telemetry: a single packet carries
+// both the AuditEvent row to insert and the SiteCalls operational-state upsert
+// for one lifecycle event of a cached outbound call. Central writes both rows
+// in one MS SQL transaction so the audit and operational mirrors never drift.
+message SiteCallOperationalDto {
+ string tracked_operation_id = 1; // GUID string ("D" format)
+ string channel = 2; // "ApiOutbound" | "DbOutbound"
+ string target = 3;
+ string source_site = 4;
+ string status = 5; // AuditStatus name
+ int32 retry_count = 6;
+ string last_error = 7; // empty when null
+ google.protobuf.Int32Value http_status = 8;
+ google.protobuf.Timestamp created_at_utc = 9;
+ google.protobuf.Timestamp updated_at_utc = 10;
+ google.protobuf.Timestamp terminal_at_utc = 11; // absent when not terminal
+}
+
+message CachedTelemetryPacket {
+ AuditEventDto audit_event = 1;
+ SiteCallOperationalDto operational = 2;
+}
+
+message CachedTelemetryBatch { repeated CachedTelemetryPacket packets = 1; }
diff --git a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs
index 3a843eb..9639242 100644
--- a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs
+++ b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs
@@ -55,19 +55,34 @@ namespace ScadaLink.Communication.Grpc {
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkiPAoPQXVk",
"aXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVk",
"aXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRz",
- "GAEgAygJKlwKB1F1YWxpdHkSFwoTUVVBTElUWV9VTlNQRUNJRklFRBAAEhAK",
- "DFFVQUxJVFlfR09PRBABEhUKEVFVQUxJVFlfVU5DRVJUQUlOEAISDwoLUVVB",
- "TElUWV9CQUQQAypdCg5BbGFybVN0YXRlRW51bRIbChdBTEFSTV9TVEFURV9V",
- "TlNQRUNJRklFRBAAEhYKEkFMQVJNX1NUQVRFX05PUk1BTBABEhYKEkFMQVJN",
- "X1NUQVRFX0FDVElWRRACKoUBCg5BbGFybUxldmVsRW51bRIUChBBTEFSTV9M",
- "RVZFTF9OT05FEAASEwoPQUxBUk1fTEVWRUxfTE9XEAESFwoTQUxBUk1fTEVW",
- "RUxfTE9XX0xPVxACEhQKEEFMQVJNX0xFVkVMX0hJR0gQAxIZChVBTEFSTV9M",
- "RVZFTF9ISUdIX0hJR0gQBDKzAQoRU2l0ZVN0cmVhbVNlcnZpY2USVQoRU3Vi",
- "c2NyaWJlSW5zdGFuY2USIS5zaXRlc3RyZWFtLkluc3RhbmNlU3RyZWFtUmVx",
- "dWVzdBobLnNpdGVzdHJlYW0uU2l0ZVN0cmVhbUV2ZW50MAESRwoRSW5nZXN0",
- "QXVkaXRFdmVudHMSGy5zaXRlc3RyZWFtLkF1ZGl0RXZlbnRCYXRjaBoVLnNp",
- "dGVzdHJlYW0uSW5nZXN0QWNrQh+qAhxTY2FkYUxpbmsuQ29tbXVuaWNhdGlv",
- "bi5HcnBjYgZwcm90bzM="));
+ "GAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRf",
+ "b3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0",
+ "GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgFIAEoCRIT",
+ "CgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0",
+ "dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUS",
+ "MgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGlt",
+ "ZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xlLnByb3Rv",
+ "YnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsyGi5nb29n",
+ "bGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0cnlQYWNr",
+ "ZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZl",
+ "bnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNpdGVD",
+ "YWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gSMgoH",
+ "cGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5UGFj",
+ "a2V0KlwKB1F1YWxpdHkSFwoTUVVBTElUWV9VTlNQRUNJRklFRBAAEhAKDFFV",
+ "QUxJVFlfR09PRBABEhUKEVFVQUxJVFlfVU5DRVJUQUlOEAISDwoLUVVBTElU",
+ "WV9CQUQQAypdCg5BbGFybVN0YXRlRW51bRIbChdBTEFSTV9TVEFURV9VTlNQ",
+ "RUNJRklFRBAAEhYKEkFMQVJNX1NUQVRFX05PUk1BTBABEhYKEkFMQVJNX1NU",
+ "QVRFX0FDVElWRRACKoUBCg5BbGFybUxldmVsRW51bRIUChBBTEFSTV9MRVZF",
+ "TF9OT05FEAASEwoPQUxBUk1fTEVWRUxfTE9XEAESFwoTQUxBUk1fTEVWRUxf",
+ "TE9XX0xPVxACEhQKEEFMQVJNX0xFVkVMX0hJR0gQAxIZChVBTEFSTV9MRVZF",
+ "TF9ISUdIX0hJR0gQBDKFAgoRU2l0ZVN0cmVhbVNlcnZpY2USVQoRU3Vic2Ny",
+ "aWJlSW5zdGFuY2USIS5zaXRlc3RyZWFtLkluc3RhbmNlU3RyZWFtUmVxdWVz",
+ "dBobLnNpdGVzdHJlYW0uU2l0ZVN0cmVhbUV2ZW50MAESRwoRSW5nZXN0QXVk",
+ "aXRFdmVudHMSGy5zaXRlc3RyZWFtLkF1ZGl0RXZlbnRCYXRjaBoVLnNpdGVz",
+ "dHJlYW0uSW5nZXN0QWNrElAKFUluZ2VzdENhY2hlZFRlbGVtZXRyeRIgLnNp",
+ "dGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gaFS5zaXRlc3RyZWFtLklu",
+ "Z2VzdEFja0IfqgIcU2NhZGFMaW5rLkNvbW11bmljYXRpb24uR3JwY2IGcHJv",
+ "dG8z"));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
@@ -77,7 +92,10 @@ namespace ScadaLink.Communication.Grpc {
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventBatch), global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null),
- new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.IngestAck), global::ScadaLink.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null)
+ new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.IngestAck), global::ScadaLink.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null),
+ new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteCallOperationalDto), global::ScadaLink.Communication.Grpc.SiteCallOperationalDto.Parser, new[]{ "TrackedOperationId", "Channel", "Target", "SourceSite", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc" }, null, null, null, null),
+ new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.CachedTelemetryPacket), global::ScadaLink.Communication.Grpc.CachedTelemetryPacket.Parser, new[]{ "AuditEvent", "Operational" }, null, null, null, null),
+ new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.CachedTelemetryBatch), global::ScadaLink.Communication.Grpc.CachedTelemetryBatch.Parser, new[]{ "Packets" }, null, null, null, null)
}));
}
#endregion
@@ -2780,6 +2798,1070 @@ namespace ScadaLink.Communication.Grpc {
}
+ ///
+ /// Audit Log (#23) M3 cached-call combined telemetry: a single packet carries
+ /// both the AuditEvent row to insert and the SiteCalls operational-state upsert
+ /// for one lifecycle event of a cached outbound call. Central writes both rows
+ /// in one MS SQL transaction so the audit and operational mirrors never drift.
+ ///
+ [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
+ public sealed partial class SiteCallOperationalDto : pb::IMessage
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ , pb::IBufferMessage
+ #endif
+ {
+ private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new SiteCallOperationalDto());
+ private pb::UnknownFieldSet _unknownFields;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public static pb::MessageParser Parser { get { return _parser; } }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public static pbr::MessageDescriptor Descriptor {
+ get { return global::ScadaLink.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[7]; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ pbr::MessageDescriptor pb::IMessage.Descriptor {
+ get { return Descriptor; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public SiteCallOperationalDto() {
+ OnConstruction();
+ }
+
+ partial void OnConstruction();
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public SiteCallOperationalDto(SiteCallOperationalDto other) : this() {
+ trackedOperationId_ = other.trackedOperationId_;
+ channel_ = other.channel_;
+ target_ = other.target_;
+ sourceSite_ = other.sourceSite_;
+ status_ = other.status_;
+ retryCount_ = other.retryCount_;
+ lastError_ = other.lastError_;
+ HttpStatus = other.HttpStatus;
+ createdAtUtc_ = other.createdAtUtc_ != null ? other.createdAtUtc_.Clone() : null;
+ updatedAtUtc_ = other.updatedAtUtc_ != null ? other.updatedAtUtc_.Clone() : null;
+ terminalAtUtc_ = other.terminalAtUtc_ != null ? other.terminalAtUtc_.Clone() : null;
+ _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public SiteCallOperationalDto Clone() {
+ return new SiteCallOperationalDto(this);
+ }
+
+ /// Field number for the "tracked_operation_id" field.
+ public const int TrackedOperationIdFieldNumber = 1;
+ private string trackedOperationId_ = "";
+ ///
+ /// GUID string ("D" format)
+ ///
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public string TrackedOperationId {
+ get { return trackedOperationId_; }
+ set {
+ trackedOperationId_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
+ }
+ }
+
+ /// Field number for the "channel" field.
+ public const int ChannelFieldNumber = 2;
+ private string channel_ = "";
+ ///
+ /// "ApiOutbound" | "DbOutbound"
+ ///
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public string Channel {
+ get { return channel_; }
+ set {
+ channel_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
+ }
+ }
+
+ /// Field number for the "target" field.
+ public const int TargetFieldNumber = 3;
+ private string target_ = "";
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public string Target {
+ get { return target_; }
+ set {
+ target_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
+ }
+ }
+
+ /// Field number for the "source_site" field.
+ public const int SourceSiteFieldNumber = 4;
+ private string sourceSite_ = "";
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public string SourceSite {
+ get { return sourceSite_; }
+ set {
+ sourceSite_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
+ }
+ }
+
+ /// Field number for the "status" field.
+ public const int StatusFieldNumber = 5;
+ private string status_ = "";
+ ///
+ /// AuditStatus name
+ ///
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public string Status {
+ get { return status_; }
+ set {
+ status_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
+ }
+ }
+
+ /// Field number for the "retry_count" field.
+ public const int RetryCountFieldNumber = 6;
+ private int retryCount_;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public int RetryCount {
+ get { return retryCount_; }
+ set {
+ retryCount_ = value;
+ }
+ }
+
+ /// Field number for the "last_error" field.
+ public const int LastErrorFieldNumber = 7;
+ private string lastError_ = "";
+ ///
+ /// empty when null
+ ///
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public string LastError {
+ get { return lastError_; }
+ set {
+ lastError_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
+ }
+ }
+
+ /// Field number for the "http_status" field.
+ public const int HttpStatusFieldNumber = 8;
+ private static readonly pb::FieldCodec _single_httpStatus_codec = pb::FieldCodec.ForStructWrapper(66);
+ private int? httpStatus_;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public int? HttpStatus {
+ get { return httpStatus_; }
+ set {
+ httpStatus_ = value;
+ }
+ }
+
+
+ /// Field number for the "created_at_utc" field.
+ public const int CreatedAtUtcFieldNumber = 9;
+ private global::Google.Protobuf.WellKnownTypes.Timestamp createdAtUtc_;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public global::Google.Protobuf.WellKnownTypes.Timestamp CreatedAtUtc {
+ get { return createdAtUtc_; }
+ set {
+ createdAtUtc_ = value;
+ }
+ }
+
+ /// Field number for the "updated_at_utc" field.
+ public const int UpdatedAtUtcFieldNumber = 10;
+ private global::Google.Protobuf.WellKnownTypes.Timestamp updatedAtUtc_;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public global::Google.Protobuf.WellKnownTypes.Timestamp UpdatedAtUtc {
+ get { return updatedAtUtc_; }
+ set {
+ updatedAtUtc_ = value;
+ }
+ }
+
+ /// Field number for the "terminal_at_utc" field.
+ public const int TerminalAtUtcFieldNumber = 11;
+ private global::Google.Protobuf.WellKnownTypes.Timestamp terminalAtUtc_;
+ ///
+ /// absent when not terminal
+ ///
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public global::Google.Protobuf.WellKnownTypes.Timestamp TerminalAtUtc {
+ get { return terminalAtUtc_; }
+ set {
+ terminalAtUtc_ = value;
+ }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override bool Equals(object other) {
+ return Equals(other as SiteCallOperationalDto);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public bool Equals(SiteCallOperationalDto other) {
+ if (ReferenceEquals(other, null)) {
+ return false;
+ }
+ if (ReferenceEquals(other, this)) {
+ return true;
+ }
+ if (TrackedOperationId != other.TrackedOperationId) return false;
+ if (Channel != other.Channel) return false;
+ if (Target != other.Target) return false;
+ if (SourceSite != other.SourceSite) return false;
+ if (Status != other.Status) return false;
+ if (RetryCount != other.RetryCount) return false;
+ if (LastError != other.LastError) return false;
+ if (HttpStatus != other.HttpStatus) return false;
+ if (!object.Equals(CreatedAtUtc, other.CreatedAtUtc)) return false;
+ if (!object.Equals(UpdatedAtUtc, other.UpdatedAtUtc)) return false;
+ if (!object.Equals(TerminalAtUtc, other.TerminalAtUtc)) return false;
+ return Equals(_unknownFields, other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override int GetHashCode() {
+ int hash = 1;
+ if (TrackedOperationId.Length != 0) hash ^= TrackedOperationId.GetHashCode();
+ if (Channel.Length != 0) hash ^= Channel.GetHashCode();
+ if (Target.Length != 0) hash ^= Target.GetHashCode();
+ if (SourceSite.Length != 0) hash ^= SourceSite.GetHashCode();
+ if (Status.Length != 0) hash ^= Status.GetHashCode();
+ if (RetryCount != 0) hash ^= RetryCount.GetHashCode();
+ if (LastError.Length != 0) hash ^= LastError.GetHashCode();
+ if (httpStatus_ != null) hash ^= HttpStatus.GetHashCode();
+ if (createdAtUtc_ != null) hash ^= CreatedAtUtc.GetHashCode();
+ if (updatedAtUtc_ != null) hash ^= UpdatedAtUtc.GetHashCode();
+ if (terminalAtUtc_ != null) hash ^= TerminalAtUtc.GetHashCode();
+ if (_unknownFields != null) {
+ hash ^= _unknownFields.GetHashCode();
+ }
+ return hash;
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override string ToString() {
+ return pb::JsonFormatter.ToDiagnosticString(this);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void WriteTo(pb::CodedOutputStream output) {
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ output.WriteRawMessage(this);
+ #else
+ if (TrackedOperationId.Length != 0) {
+ output.WriteRawTag(10);
+ output.WriteString(TrackedOperationId);
+ }
+ if (Channel.Length != 0) {
+ output.WriteRawTag(18);
+ output.WriteString(Channel);
+ }
+ if (Target.Length != 0) {
+ output.WriteRawTag(26);
+ output.WriteString(Target);
+ }
+ if (SourceSite.Length != 0) {
+ output.WriteRawTag(34);
+ output.WriteString(SourceSite);
+ }
+ if (Status.Length != 0) {
+ output.WriteRawTag(42);
+ output.WriteString(Status);
+ }
+ if (RetryCount != 0) {
+ output.WriteRawTag(48);
+ output.WriteInt32(RetryCount);
+ }
+ if (LastError.Length != 0) {
+ output.WriteRawTag(58);
+ output.WriteString(LastError);
+ }
+ if (httpStatus_ != null) {
+ _single_httpStatus_codec.WriteTagAndValue(output, HttpStatus);
+ }
+ if (createdAtUtc_ != null) {
+ output.WriteRawTag(74);
+ output.WriteMessage(CreatedAtUtc);
+ }
+ if (updatedAtUtc_ != null) {
+ output.WriteRawTag(82);
+ output.WriteMessage(UpdatedAtUtc);
+ }
+ if (terminalAtUtc_ != null) {
+ output.WriteRawTag(90);
+ output.WriteMessage(TerminalAtUtc);
+ }
+ if (_unknownFields != null) {
+ _unknownFields.WriteTo(output);
+ }
+ #endif
+ }
+
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
+ if (TrackedOperationId.Length != 0) {
+ output.WriteRawTag(10);
+ output.WriteString(TrackedOperationId);
+ }
+ if (Channel.Length != 0) {
+ output.WriteRawTag(18);
+ output.WriteString(Channel);
+ }
+ if (Target.Length != 0) {
+ output.WriteRawTag(26);
+ output.WriteString(Target);
+ }
+ if (SourceSite.Length != 0) {
+ output.WriteRawTag(34);
+ output.WriteString(SourceSite);
+ }
+ if (Status.Length != 0) {
+ output.WriteRawTag(42);
+ output.WriteString(Status);
+ }
+ if (RetryCount != 0) {
+ output.WriteRawTag(48);
+ output.WriteInt32(RetryCount);
+ }
+ if (LastError.Length != 0) {
+ output.WriteRawTag(58);
+ output.WriteString(LastError);
+ }
+ if (httpStatus_ != null) {
+ _single_httpStatus_codec.WriteTagAndValue(ref output, HttpStatus);
+ }
+ if (createdAtUtc_ != null) {
+ output.WriteRawTag(74);
+ output.WriteMessage(CreatedAtUtc);
+ }
+ if (updatedAtUtc_ != null) {
+ output.WriteRawTag(82);
+ output.WriteMessage(UpdatedAtUtc);
+ }
+ if (terminalAtUtc_ != null) {
+ output.WriteRawTag(90);
+ output.WriteMessage(TerminalAtUtc);
+ }
+ if (_unknownFields != null) {
+ _unknownFields.WriteTo(ref output);
+ }
+ }
+ #endif
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public int CalculateSize() {
+ int size = 0;
+ if (TrackedOperationId.Length != 0) {
+ size += 1 + pb::CodedOutputStream.ComputeStringSize(TrackedOperationId);
+ }
+ if (Channel.Length != 0) {
+ size += 1 + pb::CodedOutputStream.ComputeStringSize(Channel);
+ }
+ if (Target.Length != 0) {
+ size += 1 + pb::CodedOutputStream.ComputeStringSize(Target);
+ }
+ if (SourceSite.Length != 0) {
+ size += 1 + pb::CodedOutputStream.ComputeStringSize(SourceSite);
+ }
+ if (Status.Length != 0) {
+ size += 1 + pb::CodedOutputStream.ComputeStringSize(Status);
+ }
+ if (RetryCount != 0) {
+ size += 1 + pb::CodedOutputStream.ComputeInt32Size(RetryCount);
+ }
+ if (LastError.Length != 0) {
+ size += 1 + pb::CodedOutputStream.ComputeStringSize(LastError);
+ }
+ if (httpStatus_ != null) {
+ size += _single_httpStatus_codec.CalculateSizeWithTag(HttpStatus);
+ }
+ if (createdAtUtc_ != null) {
+ size += 1 + pb::CodedOutputStream.ComputeMessageSize(CreatedAtUtc);
+ }
+ if (updatedAtUtc_ != null) {
+ size += 1 + pb::CodedOutputStream.ComputeMessageSize(UpdatedAtUtc);
+ }
+ if (terminalAtUtc_ != null) {
+ size += 1 + pb::CodedOutputStream.ComputeMessageSize(TerminalAtUtc);
+ }
+ if (_unknownFields != null) {
+ size += _unknownFields.CalculateSize();
+ }
+ return size;
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void MergeFrom(SiteCallOperationalDto other) {
+ if (other == null) {
+ return;
+ }
+ if (other.TrackedOperationId.Length != 0) {
+ TrackedOperationId = other.TrackedOperationId;
+ }
+ if (other.Channel.Length != 0) {
+ Channel = other.Channel;
+ }
+ if (other.Target.Length != 0) {
+ Target = other.Target;
+ }
+ if (other.SourceSite.Length != 0) {
+ SourceSite = other.SourceSite;
+ }
+ if (other.Status.Length != 0) {
+ Status = other.Status;
+ }
+ if (other.RetryCount != 0) {
+ RetryCount = other.RetryCount;
+ }
+ if (other.LastError.Length != 0) {
+ LastError = other.LastError;
+ }
+ if (other.httpStatus_ != null) {
+ if (httpStatus_ == null || other.HttpStatus != 0) {
+ HttpStatus = other.HttpStatus;
+ }
+ }
+ if (other.createdAtUtc_ != null) {
+ if (createdAtUtc_ == null) {
+ CreatedAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
+ }
+ CreatedAtUtc.MergeFrom(other.CreatedAtUtc);
+ }
+ if (other.updatedAtUtc_ != null) {
+ if (updatedAtUtc_ == null) {
+ UpdatedAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
+ }
+ UpdatedAtUtc.MergeFrom(other.UpdatedAtUtc);
+ }
+ if (other.terminalAtUtc_ != null) {
+ if (terminalAtUtc_ == null) {
+ TerminalAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
+ }
+ TerminalAtUtc.MergeFrom(other.TerminalAtUtc);
+ }
+ _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void MergeFrom(pb::CodedInputStream input) {
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ input.ReadRawMessage(this);
+ #else
+ uint tag;
+ while ((tag = input.ReadTag()) != 0) {
+ if ((tag & 7) == 4) {
+ // Abort on any end group tag.
+ return;
+ }
+ switch(tag) {
+ default:
+ _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
+ break;
+ case 10: {
+ TrackedOperationId = input.ReadString();
+ break;
+ }
+ case 18: {
+ Channel = input.ReadString();
+ break;
+ }
+ case 26: {
+ Target = input.ReadString();
+ break;
+ }
+ case 34: {
+ SourceSite = input.ReadString();
+ break;
+ }
+ case 42: {
+ Status = input.ReadString();
+ break;
+ }
+ case 48: {
+ RetryCount = input.ReadInt32();
+ break;
+ }
+ case 58: {
+ LastError = input.ReadString();
+ break;
+ }
+ case 66: {
+ int? value = _single_httpStatus_codec.Read(input);
+ if (httpStatus_ == null || value != 0) {
+ HttpStatus = value;
+ }
+ break;
+ }
+ case 74: {
+ if (createdAtUtc_ == null) {
+ CreatedAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
+ }
+ input.ReadMessage(CreatedAtUtc);
+ break;
+ }
+ case 82: {
+ if (updatedAtUtc_ == null) {
+ UpdatedAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
+ }
+ input.ReadMessage(UpdatedAtUtc);
+ break;
+ }
+ case 90: {
+ if (terminalAtUtc_ == null) {
+ TerminalAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
+ }
+ input.ReadMessage(TerminalAtUtc);
+ break;
+ }
+ }
+ }
+ #endif
+ }
+
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
+ uint tag;
+ while ((tag = input.ReadTag()) != 0) {
+ if ((tag & 7) == 4) {
+ // Abort on any end group tag.
+ return;
+ }
+ switch(tag) {
+ default:
+ _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
+ break;
+ case 10: {
+ TrackedOperationId = input.ReadString();
+ break;
+ }
+ case 18: {
+ Channel = input.ReadString();
+ break;
+ }
+ case 26: {
+ Target = input.ReadString();
+ break;
+ }
+ case 34: {
+ SourceSite = input.ReadString();
+ break;
+ }
+ case 42: {
+ Status = input.ReadString();
+ break;
+ }
+ case 48: {
+ RetryCount = input.ReadInt32();
+ break;
+ }
+ case 58: {
+ LastError = input.ReadString();
+ break;
+ }
+ case 66: {
+ int? value = _single_httpStatus_codec.Read(ref input);
+ if (httpStatus_ == null || value != 0) {
+ HttpStatus = value;
+ }
+ break;
+ }
+ case 74: {
+ if (createdAtUtc_ == null) {
+ CreatedAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
+ }
+ input.ReadMessage(CreatedAtUtc);
+ break;
+ }
+ case 82: {
+ if (updatedAtUtc_ == null) {
+ UpdatedAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
+ }
+ input.ReadMessage(UpdatedAtUtc);
+ break;
+ }
+ case 90: {
+ if (terminalAtUtc_ == null) {
+ TerminalAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp();
+ }
+ input.ReadMessage(TerminalAtUtc);
+ break;
+ }
+ }
+ }
+ }
+ #endif
+
+ }
+
+ [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
+ public sealed partial class CachedTelemetryPacket : pb::IMessage
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ , pb::IBufferMessage
+ #endif
+ {
+ private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new CachedTelemetryPacket());
+ private pb::UnknownFieldSet _unknownFields;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public static pb::MessageParser Parser { get { return _parser; } }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public static pbr::MessageDescriptor Descriptor {
+ get { return global::ScadaLink.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[8]; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ pbr::MessageDescriptor pb::IMessage.Descriptor {
+ get { return Descriptor; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public CachedTelemetryPacket() {
+ OnConstruction();
+ }
+
+ partial void OnConstruction();
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public CachedTelemetryPacket(CachedTelemetryPacket other) : this() {
+ auditEvent_ = other.auditEvent_ != null ? other.auditEvent_.Clone() : null;
+ operational_ = other.operational_ != null ? other.operational_.Clone() : null;
+ _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public CachedTelemetryPacket Clone() {
+ return new CachedTelemetryPacket(this);
+ }
+
+ /// Field number for the "audit_event" field.
+ public const int AuditEventFieldNumber = 1;
+ private global::ScadaLink.Communication.Grpc.AuditEventDto auditEvent_;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public global::ScadaLink.Communication.Grpc.AuditEventDto AuditEvent {
+ get { return auditEvent_; }
+ set {
+ auditEvent_ = value;
+ }
+ }
+
+ /// Field number for the "operational" field.
+ public const int OperationalFieldNumber = 2;
+ private global::ScadaLink.Communication.Grpc.SiteCallOperationalDto operational_;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public global::ScadaLink.Communication.Grpc.SiteCallOperationalDto Operational {
+ get { return operational_; }
+ set {
+ operational_ = value;
+ }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override bool Equals(object other) {
+ return Equals(other as CachedTelemetryPacket);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public bool Equals(CachedTelemetryPacket other) {
+ if (ReferenceEquals(other, null)) {
+ return false;
+ }
+ if (ReferenceEquals(other, this)) {
+ return true;
+ }
+ if (!object.Equals(AuditEvent, other.AuditEvent)) return false;
+ if (!object.Equals(Operational, other.Operational)) return false;
+ return Equals(_unknownFields, other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override int GetHashCode() {
+ int hash = 1;
+ if (auditEvent_ != null) hash ^= AuditEvent.GetHashCode();
+ if (operational_ != null) hash ^= Operational.GetHashCode();
+ if (_unknownFields != null) {
+ hash ^= _unknownFields.GetHashCode();
+ }
+ return hash;
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override string ToString() {
+ return pb::JsonFormatter.ToDiagnosticString(this);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void WriteTo(pb::CodedOutputStream output) {
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ output.WriteRawMessage(this);
+ #else
+ if (auditEvent_ != null) {
+ output.WriteRawTag(10);
+ output.WriteMessage(AuditEvent);
+ }
+ if (operational_ != null) {
+ output.WriteRawTag(18);
+ output.WriteMessage(Operational);
+ }
+ if (_unknownFields != null) {
+ _unknownFields.WriteTo(output);
+ }
+ #endif
+ }
+
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
+ if (auditEvent_ != null) {
+ output.WriteRawTag(10);
+ output.WriteMessage(AuditEvent);
+ }
+ if (operational_ != null) {
+ output.WriteRawTag(18);
+ output.WriteMessage(Operational);
+ }
+ if (_unknownFields != null) {
+ _unknownFields.WriteTo(ref output);
+ }
+ }
+ #endif
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public int CalculateSize() {
+ int size = 0;
+ if (auditEvent_ != null) {
+ size += 1 + pb::CodedOutputStream.ComputeMessageSize(AuditEvent);
+ }
+ if (operational_ != null) {
+ size += 1 + pb::CodedOutputStream.ComputeMessageSize(Operational);
+ }
+ if (_unknownFields != null) {
+ size += _unknownFields.CalculateSize();
+ }
+ return size;
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void MergeFrom(CachedTelemetryPacket other) {
+ if (other == null) {
+ return;
+ }
+ if (other.auditEvent_ != null) {
+ if (auditEvent_ == null) {
+ AuditEvent = new global::ScadaLink.Communication.Grpc.AuditEventDto();
+ }
+ AuditEvent.MergeFrom(other.AuditEvent);
+ }
+ if (other.operational_ != null) {
+ if (operational_ == null) {
+ Operational = new global::ScadaLink.Communication.Grpc.SiteCallOperationalDto();
+ }
+ Operational.MergeFrom(other.Operational);
+ }
+ _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void MergeFrom(pb::CodedInputStream input) {
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ input.ReadRawMessage(this);
+ #else
+ uint tag;
+ while ((tag = input.ReadTag()) != 0) {
+ if ((tag & 7) == 4) {
+ // Abort on any end group tag.
+ return;
+ }
+ switch(tag) {
+ default:
+ _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
+ break;
+ case 10: {
+ if (auditEvent_ == null) {
+ AuditEvent = new global::ScadaLink.Communication.Grpc.AuditEventDto();
+ }
+ input.ReadMessage(AuditEvent);
+ break;
+ }
+ case 18: {
+ if (operational_ == null) {
+ Operational = new global::ScadaLink.Communication.Grpc.SiteCallOperationalDto();
+ }
+ input.ReadMessage(Operational);
+ break;
+ }
+ }
+ }
+ #endif
+ }
+
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
+ uint tag;
+ while ((tag = input.ReadTag()) != 0) {
+ if ((tag & 7) == 4) {
+ // Abort on any end group tag.
+ return;
+ }
+ switch(tag) {
+ default:
+ _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
+ break;
+ case 10: {
+ if (auditEvent_ == null) {
+ AuditEvent = new global::ScadaLink.Communication.Grpc.AuditEventDto();
+ }
+ input.ReadMessage(AuditEvent);
+ break;
+ }
+ case 18: {
+ if (operational_ == null) {
+ Operational = new global::ScadaLink.Communication.Grpc.SiteCallOperationalDto();
+ }
+ input.ReadMessage(Operational);
+ break;
+ }
+ }
+ }
+ }
+ #endif
+
+ }
+
+ [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
+ public sealed partial class CachedTelemetryBatch : pb::IMessage
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ , pb::IBufferMessage
+ #endif
+ {
+ private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new CachedTelemetryBatch());
+ private pb::UnknownFieldSet _unknownFields;
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public static pb::MessageParser Parser { get { return _parser; } }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public static pbr::MessageDescriptor Descriptor {
+ get { return global::ScadaLink.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[9]; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ pbr::MessageDescriptor pb::IMessage.Descriptor {
+ get { return Descriptor; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public CachedTelemetryBatch() {
+ OnConstruction();
+ }
+
+ partial void OnConstruction();
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public CachedTelemetryBatch(CachedTelemetryBatch other) : this() {
+ packets_ = other.packets_.Clone();
+ _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public CachedTelemetryBatch Clone() {
+ return new CachedTelemetryBatch(this);
+ }
+
+ /// Field number for the "packets" field.
+ public const int PacketsFieldNumber = 1;
+ private static readonly pb::FieldCodec _repeated_packets_codec
+ = pb::FieldCodec.ForMessage(10, global::ScadaLink.Communication.Grpc.CachedTelemetryPacket.Parser);
+ private readonly pbc::RepeatedField packets_ = new pbc::RepeatedField();
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public pbc::RepeatedField Packets {
+ get { return packets_; }
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override bool Equals(object other) {
+ return Equals(other as CachedTelemetryBatch);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public bool Equals(CachedTelemetryBatch other) {
+ if (ReferenceEquals(other, null)) {
+ return false;
+ }
+ if (ReferenceEquals(other, this)) {
+ return true;
+ }
+ if(!packets_.Equals(other.packets_)) return false;
+ return Equals(_unknownFields, other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override int GetHashCode() {
+ int hash = 1;
+ hash ^= packets_.GetHashCode();
+ if (_unknownFields != null) {
+ hash ^= _unknownFields.GetHashCode();
+ }
+ return hash;
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public override string ToString() {
+ return pb::JsonFormatter.ToDiagnosticString(this);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void WriteTo(pb::CodedOutputStream output) {
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ output.WriteRawMessage(this);
+ #else
+ packets_.WriteTo(output, _repeated_packets_codec);
+ if (_unknownFields != null) {
+ _unknownFields.WriteTo(output);
+ }
+ #endif
+ }
+
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
+ packets_.WriteTo(ref output, _repeated_packets_codec);
+ if (_unknownFields != null) {
+ _unknownFields.WriteTo(ref output);
+ }
+ }
+ #endif
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public int CalculateSize() {
+ int size = 0;
+ size += packets_.CalculateSize(_repeated_packets_codec);
+ if (_unknownFields != null) {
+ size += _unknownFields.CalculateSize();
+ }
+ return size;
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void MergeFrom(CachedTelemetryBatch other) {
+ if (other == null) {
+ return;
+ }
+ packets_.Add(other.packets_);
+ _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
+ }
+
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ public void MergeFrom(pb::CodedInputStream input) {
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ input.ReadRawMessage(this);
+ #else
+ uint tag;
+ while ((tag = input.ReadTag()) != 0) {
+ if ((tag & 7) == 4) {
+ // Abort on any end group tag.
+ return;
+ }
+ switch(tag) {
+ default:
+ _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
+ break;
+ case 10: {
+ packets_.AddEntriesFrom(input, _repeated_packets_codec);
+ break;
+ }
+ }
+ }
+ #endif
+ }
+
+ #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+ [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+ void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
+ uint tag;
+ while ((tag = input.ReadTag()) != 0) {
+ if ((tag & 7) == 4) {
+ // Abort on any end group tag.
+ return;
+ }
+ switch(tag) {
+ default:
+ _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
+ break;
+ case 10: {
+ packets_.AddEntriesFrom(ref input, _repeated_packets_codec);
+ break;
+ }
+ }
+ }
+ }
+ #endif
+
+ }
+
#endregion
}
diff --git a/src/ScadaLink.Communication/SiteStreamGrpc/SitestreamGrpc.cs b/src/ScadaLink.Communication/SiteStreamGrpc/SitestreamGrpc.cs
index 0a900cb..e7b9b33 100644
--- a/src/ScadaLink.Communication/SiteStreamGrpc/SitestreamGrpc.cs
+++ b/src/ScadaLink.Communication/SiteStreamGrpc/SitestreamGrpc.cs
@@ -53,6 +53,8 @@ namespace ScadaLink.Communication.Grpc {
static readonly grpc::Marshaller __Marshaller_sitestream_AuditEventBatch = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller __Marshaller_sitestream_IngestAck = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ScadaLink.Communication.Grpc.IngestAck.Parser));
+ [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
+ static readonly grpc::Marshaller __Marshaller_sitestream_CachedTelemetryBatch = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ScadaLink.Communication.Grpc.CachedTelemetryBatch.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method __Method_SubscribeInstance = new grpc::Method(
@@ -70,6 +72,14 @@ namespace ScadaLink.Communication.Grpc {
__Marshaller_sitestream_AuditEventBatch,
__Marshaller_sitestream_IngestAck);
+ [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
+ static readonly grpc::Method __Method_IngestCachedTelemetry = new grpc::Method(
+ grpc::MethodType.Unary,
+ __ServiceName,
+ "IngestCachedTelemetry",
+ __Marshaller_sitestream_CachedTelemetryBatch,
+ __Marshaller_sitestream_IngestAck);
+
/// Service descriptor
public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
{
@@ -92,6 +102,12 @@ namespace ScadaLink.Communication.Grpc {
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
+ [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
+ public virtual global::System.Threading.Tasks.Task IngestCachedTelemetry(global::ScadaLink.Communication.Grpc.CachedTelemetryBatch request, grpc::ServerCallContext context)
+ {
+ throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
+ }
+
}
/// Client for SiteStreamService
@@ -151,6 +167,26 @@ namespace ScadaLink.Communication.Grpc {
{
return CallInvoker.AsyncUnaryCall(__Method_IngestAuditEvents, null, options, request);
}
+ [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
+ public virtual global::ScadaLink.Communication.Grpc.IngestAck IngestCachedTelemetry(global::ScadaLink.Communication.Grpc.CachedTelemetryBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
+ {
+ return IngestCachedTelemetry(request, new grpc::CallOptions(headers, deadline, cancellationToken));
+ }
+ [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
+ public virtual global::ScadaLink.Communication.Grpc.IngestAck IngestCachedTelemetry(global::ScadaLink.Communication.Grpc.CachedTelemetryBatch request, grpc::CallOptions options)
+ {
+ return CallInvoker.BlockingUnaryCall(__Method_IngestCachedTelemetry, null, options, request);
+ }
+ [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
+ public virtual grpc::AsyncUnaryCall IngestCachedTelemetryAsync(global::ScadaLink.Communication.Grpc.CachedTelemetryBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
+ {
+ return IngestCachedTelemetryAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
+ }
+ [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
+ public virtual grpc::AsyncUnaryCall IngestCachedTelemetryAsync(global::ScadaLink.Communication.Grpc.CachedTelemetryBatch request, grpc::CallOptions options)
+ {
+ return CallInvoker.AsyncUnaryCall(__Method_IngestCachedTelemetry, null, options, request);
+ }
/// Creates a new instance of client from given ClientBaseConfiguration.
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
protected override SiteStreamServiceClient NewInstance(ClientBaseConfiguration configuration)
@@ -166,7 +202,8 @@ namespace ScadaLink.Communication.Grpc {
{
return grpc::ServerServiceDefinition.CreateBuilder()
.AddMethod(__Method_SubscribeInstance, serviceImpl.SubscribeInstance)
- .AddMethod(__Method_IngestAuditEvents, serviceImpl.IngestAuditEvents).Build();
+ .AddMethod(__Method_IngestAuditEvents, serviceImpl.IngestAuditEvents)
+ .AddMethod(__Method_IngestCachedTelemetry, serviceImpl.IngestCachedTelemetry).Build();
}
/// Register service method with a service binder with or without implementation. Useful when customizing the service binding logic.
@@ -178,6 +215,7 @@ namespace ScadaLink.Communication.Grpc {
{
serviceBinder.AddMethod(__Method_SubscribeInstance, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod(serviceImpl.SubscribeInstance));
serviceBinder.AddMethod(__Method_IngestAuditEvents, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.IngestAuditEvents));
+ serviceBinder.AddMethod(__Method_IngestCachedTelemetry, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.IngestCachedTelemetry));
}
}
diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs
new file mode 100644
index 0000000..78b7ccc
--- /dev/null
+++ b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs
@@ -0,0 +1,75 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types;
+
+namespace ScadaLink.ConfigurationDatabase.Configurations;
+
+///
+/// Maps the record to the central SiteCalls table
+/// (Site Call Audit #22, Audit Log #23 M3 Bundle B). Operational state — NOT audit —
+/// so the table is non-partitioned, standard [PRIMARY] filegroup, no DB-role
+/// restriction. Two named indexes back the Central UI's "from this site" and
+/// "in this status" queries.
+///
+public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("SiteCalls");
+
+ // PK is the strong-typed TrackedOperationId. Stored as varchar(36) by converting
+ // through the canonical "D"-format GUID string. Going through the string surface
+ // (rather than uniqueidentifier) keeps the column shape identical to how the id
+ // is serialised on the wire (gRPC strings, SQLite TEXT on the site) — one
+ // consistent format everywhere makes operational debugging far easier than
+ // mixing a uniqueidentifier central column with TEXT site columns.
+ builder.HasKey(s => s.TrackedOperationId);
+
+ builder.Property(s => s.TrackedOperationId)
+ .HasConversion(
+ id => id.Value.ToString("D"),
+ s => new TrackedOperationId(Guid.Parse(s)))
+ .HasMaxLength(36)
+ .IsUnicode(false)
+ .IsRequired();
+
+ // Enum-as-string columns: bounded varchar, ASCII.
+ builder.Property(s => s.Channel)
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .IsRequired();
+
+ builder.Property(s => s.Status)
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .IsRequired();
+
+ builder.Property(s => s.SourceSite)
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .IsRequired();
+
+ builder.Property(s => s.Target)
+ .HasMaxLength(256)
+ .IsUnicode(false)
+ .IsRequired();
+
+ // Bounded unicode message column.
+ builder.Property(s => s.LastError)
+ .HasMaxLength(1024);
+
+ // Indexes — names locked for reconciliation/migration discoverability.
+ // Source_Created backs "calls from this site" (Central UI Site Calls page,
+ // filter by SourceSite, newest first).
+ builder.HasIndex(s => new { s.SourceSite, s.CreatedAtUtc })
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_SiteCalls_Source_Created");
+
+ // Status_Updated backs "calls in this status" (e.g. parked rows awaiting
+ // operator action, newest UpdatedAtUtc first).
+ builder.HasIndex(s => new { s.Status, s.UpdatedAtUtc })
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_SiteCalls_Status_Updated");
+ }
+}
diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260520180431_AddSiteCallsTable.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520180431_AddSiteCallsTable.Designer.cs
new file mode 100644
index 0000000..a1bfd93
--- /dev/null
+++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520180431_AddSiteCallsTable.Designer.cs
@@ -0,0 +1,1619 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using ScadaLink.ConfigurationDatabase;
+
+#nullable disable
+
+namespace ScadaLink.ConfigurationDatabase.Migrations
+{
+ [DbContext(typeof(ScadaLinkDbContext))]
+ [Migration("20260520180431_AddSiteCallsTable")]
+ partial class AddSiteCallsTable
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Xml")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b =>
+ {
+ b.Property("EventId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("Actor")
+ .HasMaxLength(128)
+ .IsUnicode(false)
+ .HasColumnType("varchar(128)");
+
+ b.Property("Channel")
+ .IsRequired()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DurationMs")
+ .HasColumnType("int");
+
+ b.Property("ErrorDetail")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("Extra")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ForwardState")
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("HttpStatus")
+ .HasColumnType("int");
+
+ b.Property("IngestedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("PayloadTruncated")
+ .HasColumnType("bit");
+
+ b.Property("RequestSummary")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ResponseSummary")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("SourceInstanceId")
+ .HasMaxLength(128)
+ .IsUnicode(false)
+ .HasColumnType("varchar(128)");
+
+ b.Property("SourceScript")
+ .HasMaxLength(128)
+ .IsUnicode(false)
+ .HasColumnType("varchar(128)");
+
+ b.Property("SourceSiteId")
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("Target")
+ .HasMaxLength(256)
+ .IsUnicode(false)
+ .HasColumnType("varchar(256)");
+
+ b.HasKey("EventId", "OccurredAtUtc");
+
+ b.HasIndex("CorrelationId")
+ .HasDatabaseName("IX_AuditLog_CorrelationId")
+ .HasFilter("[CorrelationId] IS NOT NULL");
+
+ b.HasIndex("EventId")
+ .IsUnique()
+ .HasDatabaseName("UX_AuditLog_EventId");
+
+ b.HasIndex("OccurredAtUtc")
+ .IsDescending()
+ .HasDatabaseName("IX_AuditLog_OccurredAtUtc");
+
+ b.HasIndex("SourceSiteId", "OccurredAtUtc")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_AuditLog_Site_Occurred");
+
+ b.HasIndex("Target", "OccurredAtUtc")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_AuditLog_Target_Occurred")
+ .HasFilter("[Target] IS NOT NULL");
+
+ b.HasIndex("Channel", "Status", "OccurredAtUtc")
+ .IsDescending(false, false, true)
+ .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
+
+ b.ToTable("AuditLog", (string)null);
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("AfterStateJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("EntityId")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("EntityName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("User")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Action");
+
+ b.HasIndex("EntityId");
+
+ b.HasIndex("EntityType");
+
+ b.HasIndex("Timestamp");
+
+ b.HasIndex("User");
+
+ b.ToTable("AuditLogEntries");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.SiteCall", b =>
+ {
+ b.Property("TrackedOperationId")
+ .HasMaxLength(36)
+ .IsUnicode(false)
+ .HasColumnType("varchar(36)");
+
+ b.Property("Channel")
+ .IsRequired()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("HttpStatus")
+ .HasColumnType("int");
+
+ b.Property("IngestedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("LastError")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("RetryCount")
+ .HasColumnType("int");
+
+ b.Property("SourceSite")
+ .IsRequired()
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("Target")
+ .IsRequired()
+ .HasMaxLength(256)
+ .IsUnicode(false)
+ .HasColumnType("varchar(256)");
+
+ b.Property("TerminalAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.HasKey("TrackedOperationId");
+
+ b.HasIndex("SourceSite", "CreatedAtUtc")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_SiteCalls_Source_Created");
+
+ b.HasIndex("Status", "UpdatedAtUtc")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_SiteCalls_Status_Updated");
+
+ b.ToTable("SiteCalls", (string)null);
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ConfigurationJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DeployedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeploymentId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("RevisionHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeploymentId");
+
+ b.HasIndex("InstanceId")
+ .IsUnique();
+
+ b.ToTable("DeployedConfigSnapshots");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CompletedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeployedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeployedBy")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("DeploymentId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ErrorMessage")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("RevisionHash")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeployedAt");
+
+ b.HasIndex("DeploymentId")
+ .IsUnique();
+
+ b.HasIndex("InstanceId");
+
+ b.ToTable("DeploymentRecords");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ArtifactType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("DeployedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeployedBy")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("PerSiteStatus")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeployedAt");
+
+ b.ToTable("SystemArtifactDeploymentRecords");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ConnectionString")
+ .IsRequired()
+ .HasMaxLength(8000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("MaxRetries")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("RetryDelay")
+ .HasColumnType("time");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("DatabaseConnectionDefinitions");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AuthConfiguration")
+ .HasMaxLength(8000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("AuthType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("EndpointUrl")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("MaxRetries")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("RetryDelay")
+ .HasColumnType("time");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("ExternalSystemDefinitions");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ExternalSystemDefinitionId")
+ .HasColumnType("int");
+
+ b.Property("HttpMethod")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("nvarchar(10)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParameterDefinitions")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("ReturnDefinition")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExternalSystemDefinitionId", "Name")
+ .IsUnique();
+
+ b.ToTable("ExternalSystemMethods");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("IsEnabled")
+ .HasColumnType("bit");
+
+ b.Property("KeyHash")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("KeyHash")
+ .IsUnique();
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("ApiKeys");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ApprovedApiKeyIds")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParameterDefinitions")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("ReturnDefinition")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("Script")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TimeoutSeconds")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("ApiMethods");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParentAreaId")
+ .HasColumnType("int");
+
+ b.Property("SiteId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentAreaId");
+
+ b.HasIndex("SiteId", "ParentAreaId", "Name")
+ .IsUnique()
+ .HasFilter("[ParentAreaId] IS NOT NULL");
+
+ b.ToTable("Areas");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AreaId")
+ .HasColumnType("int");
+
+ b.Property("SiteId")
+ .HasColumnType("int");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("TemplateId")
+ .HasColumnType("int");
+
+ b.Property("UniqueName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AreaId");
+
+ b.HasIndex("TemplateId");
+
+ b.HasIndex("SiteId", "UniqueName")
+ .IsUnique();
+
+ b.ToTable("Instances");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AlarmCanonicalName")
+ .IsRequired()
+ .HasMaxLength(400)
+ .HasColumnType("nvarchar(400)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("PriorityLevelOverride")
+ .HasColumnType("int");
+
+ b.Property("TriggerConfigurationOverride")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId", "AlarmCanonicalName")
+ .IsUnique();
+
+ b.ToTable("InstanceAlarmOverrides");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AttributeName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("OverrideValue")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId", "AttributeName")
+ .IsUnique();
+
+ b.ToTable("InstanceAttributeOverrides");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AttributeName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("DataConnectionId")
+ .HasColumnType("int");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DataConnectionId");
+
+ b.HasIndex("InstanceId", "AttributeName")
+ .IsUnique();
+
+ b.ToTable("InstanceConnectionBindings");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.Notification", b =>
+ {
+ b.Property("NotificationId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeliveredAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("LastAttemptAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("LastError")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("ListName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("NextAttemptAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("ResolvedTargets")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RetryCount")
+ .HasColumnType("int");
+
+ b.Property("SiteEnqueuedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("SourceInstanceId")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("SourceScript")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("SourceSiteId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("TypeData")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("NotificationId");
+
+ b.HasIndex("SourceSiteId", "CreatedAt");
+
+ b.HasIndex("Status", "NextAttemptAt");
+
+ b.ToTable("Notifications");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("NotificationLists");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("EmailAddress")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("NotificationListId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NotificationListId");
+
+ b.ToTable("NotificationRecipients");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AuthType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("ConnectionTimeoutSeconds")
+ .HasColumnType("int");
+
+ b.Property("Credentials")
+ .HasMaxLength(8000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("FromAddress")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("MaxConcurrentConnections")
+ .HasColumnType("int");
+
+ b.Property("MaxRetries")
+ .HasColumnType("int");
+
+ b.Property("Port")
+ .HasColumnType("int");
+
+ b.Property("RetryDelay")
+ .HasColumnType("time");
+
+ b.Property("TlsMode")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.HasKey("Id");
+
+ b.ToTable("SmtpConfigurations");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParameterDefinitions")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("ReturnDefinition")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("SharedScripts");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("LdapGroupName")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LdapGroupName")
+ .IsUnique();
+
+ b.ToTable("LdapGroupMappings");
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ LdapGroupName = "SCADA-Admins",
+ Role = "Admin"
+ },
+ new
+ {
+ Id = 2,
+ LdapGroupName = "SCADA-Designers",
+ Role = "Design"
+ },
+ new
+ {
+ Id = 3,
+ LdapGroupName = "SCADA-Deploy-All",
+ Role = "Deployment"
+ },
+ new
+ {
+ Id = 4,
+ LdapGroupName = "SCADA-Deploy-SiteA",
+ Role = "Deployment"
+ });
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("LdapGroupMappingId")
+ .HasColumnType("int");
+
+ b.Property("SiteId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SiteId");
+
+ b.HasIndex("LdapGroupMappingId", "SiteId")
+ .IsUnique();
+
+ b.ToTable("SiteScopeRules");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("BackupConfiguration")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("FailoverRetryCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(3);
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("PrimaryConfiguration")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("Protocol")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("SiteId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SiteId", "Name")
+ .IsUnique();
+
+ b.ToTable("DataConnections");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("GrpcNodeAAddress")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("GrpcNodeBAddress")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("NodeAAddress")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("NodeBAddress")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("SiteIdentifier")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("SiteIdentifier")
+ .IsUnique();
+
+ b.ToTable("Sites");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b =>
+ {
+ b.Property