diff --git a/Directory.Packages.props b/Directory.Packages.props
index 21464c0..d8f854e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -64,6 +64,14 @@
+
+
diff --git a/ScadaLink.slnx b/ScadaLink.slnx
index 9b312e4..21b4f3a 100644
--- a/ScadaLink.slnx
+++ b/ScadaLink.slnx
@@ -1,5 +1,6 @@
+
@@ -22,6 +23,7 @@
+
diff --git a/alog.md b/alog.md
index 9cc14d5..e68b91d 100644
--- a/alog.md
+++ b/alog.md
@@ -109,14 +109,14 @@ Single wide table, polymorphic by `Channel` + `Kind` discriminators, JSON payloa
| `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). |
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
-| `Kind` | `varchar(32)` | Channel-specific event kind (see table below). |
+| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events (inbound API, central notification dispatch). |
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
| `Actor` | `varchar(128)` NULL | Inbound API: API key name. Outbound: script identity. Central: system user. |
| `Target` | `varchar(256)` NULL | Outbound API: external system + method. DB: connection name. Notification: list name. Inbound API: method name. |
-| `Status` | `varchar(32)` | Outcome of *this event*: `Success`, `TransientFailure`, `PermanentFailure`, `Enqueued`, `Retrying`, `Delivered`, `Parked`, `Discarded`. |
+| `Status` | `varchar(32)` | Outcome of *this event*: `Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`. |
| `HttpStatus` | `int` NULL | HTTP-bearing events only. |
| `DurationMs` | `int` NULL | Call/attempt duration. |
| `ErrorMessage` | `nvarchar(1024)` NULL | Truncated; `ErrorDetail` for full text. |
@@ -135,14 +135,20 @@ Single wide table, polymorphic by `Channel` + `Kind` discriminators, JSON payloa
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X."
- Partitioning by month on `OccurredAtUtc` from day one (purge becomes a partition switch instead of a delete storm).
-**`Kind` values by channel:**
+**`Kind` values (flat — 10 discriminators across all channels):**
-| Channel | Kinds |
+| Kind | Fires when |
|---|---|
-| `ApiOutbound` | `SyncCall`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
-| `DbOutbound` | `SyncWrite`, `SyncRead`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
-| `Notification` | `Enqueued`, `Attempt`, `Terminal` |
-| `ApiInbound` | `Completed` (one row per request, written at request end with final status) |
+| `ApiCall` | Sync `ExternalSystem.Call(...)` returns (success or permanent failure). One row per call. |
+| `ApiCallCached` | A cached outbound-API attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
+| `DbWrite` | Sync `Database.Connection().Execute*(...)` / `ExecuteReader(...)` completes. One row per call. |
+| `DbWriteCached` | A cached outbound-DB attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
+| `NotifySend` | Script's `Notify.Send(...)` is enqueued on the site — first row in a notification's lifecycle (`Status=Submitted`). |
+| `NotifyDeliver` | Central Notification Outbox dispatcher records a delivery attempt (`Attempted`) or terminal outcome (`Delivered`/`Parked`/`Discarded`). |
+| `InboundRequest` | An inbound API request completes — one row per request, written at request end with final status. |
+| `InboundAuthFailure` | An inbound API request was rejected at the auth boundary (bad/missing key). One row, `Status=Failed`, `HttpStatus=401`. |
+| `CachedSubmit` | Script-side enqueue of a cached call (`ExternalSystem.CachedCall` / `Database.CachedWrite`); first row in the cached-call lifecycle, written to site SQLite before any forward attempt. |
+| `CachedResolve` | Terminal row for a cached operation — `Status` = `Delivered` / `Failed` / `Parked` / `Discarded`. |
### Site: `AuditLog` (SQLite)
@@ -215,13 +221,13 @@ This is the same self-healing pattern Site Call Audit uses for `SiteCalls`.
Events that originate at central never touch site SQLite:
-- **Inbound API** — request completed at central; one `ApiInbound`/`Completed` row written via `ICentralAuditWriter` synchronously inside the request handler middleware before the HTTP response is flushed.
-- **Notification Outbox dispatcher** — each delivery attempt writes a `Notification`/`Attempt` row; terminal status writes a `Notification`/`Terminal` row. (The site-originated `Notification`/`Enqueued` row arrives via §6.2.)
+- **Inbound API** — request completed at central; one `ApiInbound`/`InboundRequest` row written via `ICentralAuditWriter` synchronously inside the request handler middleware before the HTTP response is flushed. Auth failures emit `ApiInbound`/`InboundAuthFailure` instead.
+- **Notification Outbox dispatcher** — each delivery attempt writes a `Notification`/`NotifyDeliver` row with `Status=Attempted`; terminal status writes a `Notification`/`NotifyDeliver` row with `Status=Delivered`/`Parked`/`Discarded`. (The site-originated `Notification`/`NotifySend` row, `Status=Submitted`, arrives via §6.2.)
Central direct-writes use the same insert-if-not-exists semantics keyed on `EventId`, so a retried request handler can't produce duplicates.
### 6.5 Cached operations — site emits, central writes twice
-For `ExternalSystem.CachedCall` and `Database.CachedWrite`, the **site** is the source of truth for every audit row. The site writes each lifecycle event (`CachedEnqueued`, `CachedAttempt`, `CachedTerminal`) to its local SQLite `AuditLog` on the hot path (or on the retry tick for `CachedAttempt`), then forwards via the same telemetry channel described in §6.2. The telemetry message format gains the audit-row fields additively — one packet per lifecycle transition carries both the operational state update AND the audit row content.
+For `ExternalSystem.CachedCall` and `Database.CachedWrite`, the **site** is the source of truth for every audit row. The site writes each lifecycle event — `CachedSubmit` (`Status=Submitted`), then `ApiCallCached`/`DbWriteCached` rows for the forward-ack (`Status=Forwarded`) and each retry (`Status=Attempted`), then a terminal `CachedResolve` row (`Status=Delivered`/`Failed`/`Parked`/`Discarded`) — to its local SQLite `AuditLog` on the hot path (or on the retry tick for `Attempted` rows), then forwards via the same telemetry channel described in §6.2. The telemetry message format gains the audit-row fields additively — one packet per lifecycle transition carries both the operational state update AND the audit row content.
On receipt, central does two things in **one transaction**:
@@ -243,13 +249,13 @@ Worked examples — what each `Channel`/`Kind` row actually looks like. (Other c
```
EventId =
Channel = ApiOutbound
-Kind = SyncCall
+Kind = ApiCall
CorrelationId = NULL -- one-shot, no operation to correlate
SourceSiteId = "site-01"
SourceInstance = "Plant1.Boiler"
SourceScript = "OnHourly"
Target = "Weather/GetForecast"
-Status = Success
+Status = Delivered
HttpStatus = 200
DurationMs = 142
RequestSummary = '{"city":"Dublin"}' -- truncated to cap
@@ -259,11 +265,12 @@ ResponseSummary= '{"tempC":11.4,...}' -- truncated to cap
**Cached call** (`ExternalSystem.CachedCall(...)`, hits a 500, retries, succeeds on attempt 3):
```
-1. Kind=CachedEnqueued Status=Enqueued CorrelationId=
-2. Kind=CachedAttempt Status=TransientFailure HttpStatus=500 CorrelationId=
-3. Kind=CachedAttempt Status=TransientFailure HttpStatus=500 CorrelationId=
-4. Kind=CachedAttempt Status=Success HttpStatus=200 CorrelationId=
-5. Kind=CachedTerminal Status=Delivered CorrelationId=
+1. Kind=CachedSubmit Status=Submitted CorrelationId=
+2. Kind=ApiCallCached Status=Forwarded CorrelationId=
+3. Kind=ApiCallCached Status=Attempted HttpStatus=500 CorrelationId=
+4. Kind=ApiCallCached Status=Attempted HttpStatus=500 CorrelationId=
+5. Kind=ApiCallCached Status=Attempted HttpStatus=200 CorrelationId=
+6. Kind=CachedResolve Status=Delivered CorrelationId=
```
The shadow of the `SiteCalls` row's lifecycle, but immutable and time-ordered.
@@ -274,10 +281,10 @@ The shadow of the `SiteCalls` row's lifecycle, but immutable and time-ordered.
```
Channel = DbOutbound
-Kind = SyncWrite
+Kind = DbWrite
Target = "PlantDB" -- connection name only, not server
CorrelationId = NULL
-Status = Success
+Status = Delivered
DurationMs = 9
RequestSummary = "INSERT INTO Readings(ts,val) VALUES (@p0,@p1)" -- SQL text
Extra = '{"rowsAffected":1,"params":{"p0":"2026-05-20T14:00Z","p1":42.7}}' -- values captured by default
@@ -288,23 +295,25 @@ Extra = '{"rowsAffected":1,"params":{"p0":"2026-05-20T14:00Z","p1":42.7
```
Channel = DbOutbound
-Kind = SyncRead
-Status = Success
+Kind = DbWrite
+Status = Delivered
DurationMs = 31
RequestSummary = "SELECT id, value FROM Readings WHERE ts > @p0"
Extra = '{"rowsReturned":42}'
ResponseSummary= NULL -- rows not captured by default; opt-in per connection
```
-**Cached write** — same five-row lifecycle as the cached API example.
+(Reads and writes share the `DbWrite` kind — the kind distinguishes the trust-boundary call shape, not the SQL verb. Distinguish by `RequestSummary` / `Extra.rowsAffected` vs `Extra.rowsReturned` when needed.)
+
+**Cached write** — same multi-row lifecycle as the cached API example, using `Kind=DbWriteCached` for the `Forwarded` / `Attempted` rows in place of `ApiCallCached`.
### 7.3 `Notification` — outbound notifications
```
-1. Kind=Enqueued Status=Enqueued CorrelationId= SourceSiteId="site-01" SourceInstance="Plant1.Boiler"
-2. Kind=Attempt Status=TransientFailure ErrorMessage="SMTP 451 ..." CorrelationId= SourceSiteId=NULL (dispatch is central)
-3. Kind=Attempt Status=Success CorrelationId=
-4. Kind=Terminal Status=Delivered CorrelationId=
+1. Kind=NotifySend Status=Submitted CorrelationId= SourceSiteId="site-01" SourceInstance="Plant1.Boiler"
+2. Kind=NotifyDeliver Status=Attempted ErrorMessage="SMTP 451 ..." CorrelationId= SourceSiteId=NULL (dispatch is central)
+3. Kind=NotifyDeliver Status=Attempted CorrelationId=
+4. Kind=NotifyDeliver Status=Delivered CorrelationId=
Target = "OpsTeamEmail" -- notification list name
Extra = '{"resolvedTargets":["a@x.com","b@x.com"], "subject":"Boiler high temp"}'
RequestSummary = '...body, truncated...'
@@ -318,20 +327,20 @@ One row per request, written at request completion:
```
Channel = ApiInbound
-Kind = Completed
+Kind = InboundRequest
CorrelationId = -- the request's correlation header (or generated)
SourceSiteId = NULL -- central-originated event
Actor = "AcmeSCADA" -- API key name (NOT the key itself)
Target = "RecordReading" -- inbound method name
-Status = Success | PermanentFailure -- mapped from final HTTP outcome
-HttpStatus = 200 | 400 | 401 | 500
+Status = Delivered | Failed -- mapped from final HTTP outcome
+HttpStatus = 200 | 400 | 500
DurationMs = 73
RequestSummary = '{"siteId":"...","value":12.4}' -- truncated; secrets/PII per redaction policy
ResponseSummary= '{"ok":true}' -- full body on 5xx
Extra = '{"remoteIp":"203.0.113.42","userAgent":"...","scriptInvoked":"RecordReading.Handle"}'
```
-A bad API key → row with `Status=PermanentFailure`, `HttpStatus=401`, `Actor=NULL`, `Extra` carries `remoteIp` for abuse triage.
+A bad API key → separate kind: `Kind=InboundAuthFailure`, `Status=Failed`, `HttpStatus=401`, `Actor=NULL`, `Extra` carries `remoteIp` for abuse triage.
---
@@ -339,7 +348,7 @@ A bad API key → row with `Status=PermanentFailure`, `HttpStatus=401`, `Actor=N
### 8.1 Truncation
- Default cap: **8 KB** for each of `RequestSummary` and `ResponseSummary`. Configurable globally; per-target overrides allowed (§8.4).
-- On any non-`Success` row, the cap is raised to **64 KB** for that row — error context is precious.
+- On any error row (`Status IN ('Failed', 'Parked', 'Discarded')`), the cap is raised to **64 KB** for that row — error context is precious.
- When a body is truncated, `PayloadTruncated = 1` and the captured prefix is preserved verbatim (UTF-8 byte-safe truncation, no mid-character cuts).
- Bodies exceeding the larger cap are still truncated; full bodies are never stored.
@@ -426,7 +435,7 @@ Lives under a new **Audit** nav group in Central UI (sibling to **Notifications*
- Target (text search — system+method, DB connection, list name).
- Actor (text search — inbound API key name).
- CorrelationId (paste a `TrackedOperationId` / `NotificationId` / request-id to see its full event sequence).
-- "Errors only" toggle (`Status NOT IN (Success, Delivered, Enqueued)`).
+- "Errors only" toggle (`Status IN ('Failed', 'Parked', 'Discarded')`).
**Results grid:**
- Columns (resizable, reorderable, persisted per user): `OccurredAtUtc`, `Site`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`, `HttpStatus`, `ErrorMessage`.
@@ -450,7 +459,7 @@ Lives under a new **Audit** nav group in Central UI (sibling to **Notifications*
### 10.3 Health dashboard tiles
Three new tiles in an "Audit" KPI group:
- **Audit volume** — events/min global + per-site sparkline.
-- **Audit error rate** — % non-`Success` rows, rolling 5 min.
+- **Audit error rate** — % rows where `Status IN ('Failed', 'Parked', 'Discarded')`, rolling 5 min.
- **Audit backlog** — sum of `Pending` site rows; click → per-site breakdown.
### 10.4 Export
@@ -516,12 +525,12 @@ Rough back-of-envelope; load testing will confirm.
### 13.1 Per-site event rate (assumed nominal site)
| Channel/Kind | Typ events/min | Peak events/min |
|---|---:|---:|
-| `ApiOutbound.SyncCall` | 10 | 100 |
-| `ApiOutbound.Cached*` (~4 rows/op) | 4 | 20 |
-| `DbOutbound.SyncWrite` | 30 | 300 |
-| `DbOutbound.SyncRead` | 60 | 600 |
-| `DbOutbound.Cached*` (~4 rows/op) | 4 | 20 |
-| `Notification.Enqueued` (site-emit) | 1 | 10 |
+| `ApiOutbound.ApiCall` | 10 | 100 |
+| `ApiOutbound.ApiCallCached` (~4 rows/op incl. `CachedSubmit`/`CachedResolve`) | 4 | 20 |
+| `DbOutbound.DbWrite` (writes) | 30 | 300 |
+| `DbOutbound.DbWrite` (reads) | 60 | 600 |
+| `DbOutbound.DbWriteCached` (~4 rows/op incl. `CachedSubmit`/`CachedResolve`) | 4 | 20 |
+| `Notification.NotifySend` (site-emit) | 1 | 10 |
| **Per-site total** | **~110** | **~1,050** |
### 13.2 Central total (50-site deployment)
@@ -545,7 +554,7 @@ MS SQL handles this with batched ingest and the time-aligned indexes.
### 13.6 Levers
- Reduce `DefaultCapBytes` per §8.1.
-- Tighten per-channel retention per §12.1 (especially `DbOutbound.SyncRead`).
+- Tighten per-channel retention per §12.1 (especially `DbOutbound.DbWrite` read traffic).
- Defer to v1.x: Parquet archival to object storage before purge (§15.2).
---
@@ -554,7 +563,7 @@ MS SQL handles this with batched ingest and the time-aligned indexes.
### 14.1 New Audit Log KPIs
- **Volume** — events/min, global + per-site.
-- **Error rate** — % non-`Success` rows, rolling 5 min.
+- **Error rate** — % rows where `Status IN ('Failed', 'Parked', 'Discarded')`, rolling 5 min.
- **Backlog** — sum of `Pending` site rows.
- **Top inbound callers** — top-10 `Actor` by request count, last 1h.
- **Top outbound 5xx** — top-10 `Target` by 5xx-status count, last 1h.
@@ -590,6 +599,6 @@ A monthly job dumps the closing partition to Parquet on operator-configured obje
| 3 | Hash-chain tamper evidence (§11.4) | Deferred to v1.x. v1 enforces append-only via DB grants only. |
| 4 | Parquet archival to object storage (§15.2) | Deferred to v1.x. |
| 5 | Per-channel retention overrides (§12.1) | Deferred to v1.x. v1 uses a single global `RetentionDays`. |
-| 6 | Default payload cap | **8 KB** for `RequestSummary` / `ResponseSummary`; **64 KB** on non-`Success` rows. |
+| 6 | Default payload cap | **8 KB** for `RequestSummary` / `ResponseSummary`; **64 KB** on error rows (`Status IN ('Failed', 'Parked', 'Discarded')`). |
All earlier design decisions (purpose, topology, scope, payload depth, lifecycle granularity, retention default, site→central path, UI shape, cached-call audit emission, SQL parameter capture, never-fail-on-audit-failure) are also locked. See §1–§15.
diff --git a/docs/plans/2026-05-20-auditlog-m1-foundation.md b/docs/plans/2026-05-20-auditlog-m1-foundation.md
new file mode 100644
index 0000000..459dfed
--- /dev/null
+++ b/docs/plans/2026-05-20-auditlog-m1-foundation.md
@@ -0,0 +1,324 @@
+# Audit Log #23 — M1 Foundation Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task (bundled cadence per `feedback_subagent_cadence`).
+
+**Goal:** Land the `AuditLog` table (monthly-partitioned) plus DB roles in MS SQL, and add the Commons types + EF repo + new `ScadaLink.AuditLog` project skeleton that every later milestone depends on. After M1 the database is ready, the new project is wired into the solution, and `dotnet build && dotnet test` are both green.
+
+**Architecture:** New `AuditEvent` record + audit enums + writer interfaces in Commons. New EF entity configuration + EF Core migration creating `AuditLog` table aligned to `ps_AuditLog_Month` partition scheme on `OccurredAtUtc`, plus `scadalink_audit_writer` and `scadalink_audit_purger` SQL roles. New `IAuditLogRepository` with append-only surface (no Update, no row-delete). New `src/ScadaLink.AuditLog/` project skeleton + `AuditLogOptions`.
+
+**Tech Stack:** .NET 10 / EF Core 10.0.7 / Microsoft.Data.SqlClient 6.0.2 / xUnit 2.9.3 / running `infra/mssql` container for integration tests.
+
+**Brainstorm decisions (locked):**
+- **MSSQL test harness:** integration tests hit the existing `infra/mssql` container (require `cd infra && docker compose up -d`).
+- **AuditEvent shape:** one record with nullable `IngestedAtUtc` (set centrally) and nullable `ForwardState` (set site-locally).
+- **Filegroup:** PRIMARY, hard-coded.
+- **Indexes:** five named explicitly via `.HasDatabaseName("IX_AuditLog_…")`.
+
+**Pre-existing reality:**
+- `Entities/Audit/AuditLogEntry.cs` (config-audit, 9 cols) **coexists with** new `AuditEvent` — no rename, no removal.
+- `IAuditService` (config-audit) is distinct from new `IAuditWriter` / `ICentralAuditWriter`.
+- `tests/ScadaLink.IntegrationTests/` uses EF in-memory — NOT usable for partition/role tests.
+- Roadmap M1-T10 (project skeleton) must run before M1-T9 (options class). **Swapped in this plan.**
+
+---
+
+## Bundles (cadence-aligned)
+
+Tasks 1–11 from the roadmap are grouped into 6 bundles. Each bundle = one implementer dispatch + one combined spec+quality reviewer. The final cross-bundle reviewer runs over the whole branch.
+
+- **Bundle A — Commons types** (roadmap T1+T2+T3+T4): enums, AuditEvent record + ForwardState enum, IAuditWriter / ICentralAuditWriter, telemetry message DTOs.
+- **Bundle B — EF entity mapping** (T5): DbSet + IEntityTypeConfiguration + indexes.
+- **Bundle C — Migration with partitioning + DB roles** (T6+T7 merged — one migration file).
+- **Bundle D — Repository** (T8): IAuditLogRepository + EF implementation + DI registration.
+- **Bundle E — AuditLog project skeleton + options** (T10 then T9): new `src/ScadaLink.AuditLog/` project + `AuditLogOptions`.
+- **Bundle F — Docs paper trail** (T11): controller-direct edit; no subagent needed for a 1–3 line update.
+
+---
+
+## Bundle A — Commons types
+
+### Task 1: Add audit enums to Commons
+
+**Files:**
+- Create: `src/ScadaLink.Commons/Types/Enums/AuditChannel.cs`
+- Create: `src/ScadaLink.Commons/Types/Enums/AuditKind.cs`
+- Create: `src/ScadaLink.Commons/Types/Enums/AuditStatus.cs`
+- Create: `src/ScadaLink.Commons/Types/Enums/AuditForwardState.cs`
+- Create: `tests/ScadaLink.Commons.Tests/Types/Enums/AuditEnumTests.cs`
+
+**AuditChannel members** (4): `ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`.
+
+**AuditKind members** (10, per alog.md §4): `ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`.
+
+**AuditStatus members** (8, per alog.md §4): `Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`.
+
+**AuditForwardState members** (3): `Pending`, `Forwarded`, `Reconciled`.
+
+**Steps:**
+1. Failing tests assert each enum's exact member set via `Enum.GetValues(typeof(T)).Cast().Select(x => x.ToString())` against a string-array literal.
+2. Run: fail (enums don't exist).
+3. Implement the four enums (no `[Flags]`).
+4. Run: pass.
+5. Commit: `feat(commons): add Audit{Channel,Kind,Status,ForwardState} enums for #23`.
+
+### Task 2: Add AuditEvent record
+
+**Files:**
+- Create: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — `public sealed record AuditEvent` with the 20 central columns per alog.md §4, plus nullable `AuditForwardState? ForwardState` and nullable `DateTime? IngestedAtUtc`.
+- Create: `tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs`.
+
+Properties (in alog.md §4 order):
+`Guid EventId`, `DateTime OccurredAtUtc`, `DateTime? IngestedAtUtc`, `AuditChannel Channel`, `AuditKind Kind`, `Guid? CorrelationId`, `string? SourceSiteId`, `string? SourceInstanceId`, `string? SourceScript`, `string? Actor`, `string? Target`, `AuditStatus Status`, `int? HttpStatus`, `int? DurationMs`, `string? ErrorMessage`, `string? ErrorDetail`, `string? RequestSummary`, `string? ResponseSummary`, `bool PayloadTruncated`, `string? Extra`, `AuditForwardState? ForwardState`.
+
+**Steps:**
+1. Failing test constructs an `AuditEvent`, asserts each property reads back as set, asserts `with` expression produces a new instance with one field changed.
+2. Run: fail.
+3. Implement record with all properties as `init`-only.
+4. Run: pass.
+5. Commit: `feat(commons): add AuditEvent record (#23)`.
+
+### Task 3: Add IAuditWriter and ICentralAuditWriter
+
+**Files:**
+- Create: `src/ScadaLink.Commons/Interfaces/Services/IAuditWriter.cs`
+- Create: `src/ScadaLink.Commons/Interfaces/Services/ICentralAuditWriter.cs`
+- Create: `tests/ScadaLink.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs`
+
+Both interfaces expose `Task WriteAsync(AuditEvent evt, CancellationToken ct = default)`. XML doc comments name Audit Log #23 as the owner; `IAuditWriter` is the abstraction the boundary code calls, `ICentralAuditWriter` is the central-only flavor (used by direct-write paths in M2+).
+
+**Steps:**
+1. Failing reflection test: `typeof(IAuditWriter).GetMethod("WriteAsync")` returns a method whose parameters are `(AuditEvent, CancellationToken)` and return type is `Task`. Same for `ICentralAuditWriter`.
+2. Run: fail.
+3. Implement both interfaces with XML docs.
+4. Run: pass.
+5. Commit: `feat(commons): add IAuditWriter and ICentralAuditWriter (#23)`.
+
+### Task 4: Add audit telemetry + pull message DTOs
+
+**Files:**
+- Create: `src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs` — `public sealed record AuditTelemetryEnvelope(Guid EnvelopeId, string SourceSiteId, IReadOnlyList Events)`.
+- Create: `src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs` — `public sealed record PullAuditEventsRequest(string SourceSiteId, DateTime SinceUtc, int BatchSize)`.
+- Create: `src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs` — `public sealed record PullAuditEventsResponse(IReadOnlyList Events, bool MoreAvailable)`.
+- Create: `tests/ScadaLink.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs`.
+
+**Steps:**
+1. Failing test constructs envelope with 3 events and asserts immutability and enumerability.
+2. Failing test constructs `PullAuditEventsRequest` + `PullAuditEventsResponse` with `MoreAvailable=true`.
+3. Run: fail.
+4. Implement records.
+5. Run: pass.
+6. Commit: `feat(commons): add audit telemetry + pull message DTOs (#23)`.
+
+**Bundle A acceptance:** Commons project compiles. Four enum tests, AuditEvent test, two interface contract tests, two telemetry-message tests all green. No existing tests regress.
+
+---
+
+## Bundle B — EF entity mapping
+
+### Task 5: Extend ScadaLinkDbContext + add IEntityTypeConfiguration
+
+**Files:**
+- Modify: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs` — add `public DbSet AuditLogs => Set();` in the existing `// Audit` section, **directly after** the existing `AuditLogEntries` DbSet. Do not remove or modify `AuditLogEntries`.
+- Create: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — `IEntityTypeConfiguration` mapping to table `AuditLog`, columns per alog.md §4 (with max lengths), PK on `EventId`, enum columns stored as `varchar(32)` via `HasConversion().HasMaxLength(32)`. **No partition function declared here** — that goes in the migration's raw SQL.
+
+Five indexes with explicit names:
+- `IX_AuditLog_OccurredAtUtc` on (`OccurredAtUtc` desc)
+- `IX_AuditLog_Site_Occurred` on (`SourceSiteId`, `OccurredAtUtc` desc)
+- `IX_AuditLog_CorrelationId` on (`CorrelationId`) where `CorrelationId IS NOT NULL`
+- `IX_AuditLog_Channel_Status_Occurred` on (`Channel`, `Status`, `OccurredAtUtc` desc)
+- `IX_AuditLog_Target_Occurred` on (`Target`, `OccurredAtUtc` desc) where `Target IS NOT NULL`
+
+- Modify: `OnModelCreating` — apply via `modelBuilder.ApplyConfiguration(new AuditLogEntityTypeConfiguration())`.
+- Create: `tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs` — use a `ModelBuilder` directly (no DbContext required) and assert:
+ - mapped table name is `AuditLog`,
+ - PK is `EventId`,
+ - exactly 21 properties are mapped (20 + ForwardState; IngestedAtUtc is one of the 20 per spec; but ForwardState is the +1),
+ - the five indexes exist with the documented names.
+
+**Steps:**
+1. Failing test: model asserts on table name + PK + property count.
+2. Implement config + apply in `OnModelCreating`; add the DbSet.
+3. Failing test: model asserts five named indexes.
+4. Add `HasIndex(...).HasDatabaseName(...)` for each.
+5. Run: pass.
+6. Commit: `feat(configdb): map AuditEvent to AuditLog table with PK and five named indexes (#23)`.
+
+**Bundle B acceptance:** ConfigurationDatabase project compiles. Mapping test passes. No existing ConfigurationDatabase.Tests regress.
+
+---
+
+## Bundle C — Migration with partitioning + DB roles
+
+### Task 6+7 (merged): Create migration with partition function/scheme/table + DB roles
+
+**Files:**
+- Generate: `src/ScadaLink.ConfigurationDatabase/Migrations/_AddAuditLogTable.cs` via:
+ ```
+ dotnet ef migrations add AddAuditLogTable --project src/ScadaLink.ConfigurationDatabase \
+ --startup-project src/ScadaLink.Host --output-dir Migrations
+ ```
+- Customize the migration's `Up()`:
+ 1. Raw SQL: create partition function `pf_AuditLog_Month` (RANGE RIGHT FOR VALUES with month-boundaries from `2026-01-01` through `2027-12-01` UTC), and partition scheme `ps_AuditLog_Month` ALL TO ([PRIMARY]).
+ 2. Drop EF's auto-generated `CREATE TABLE` and replace with raw SQL that creates `AuditLog` ON `ps_AuditLog_Month(OccurredAtUtc)`. (Or: let EF generate the table, then `ALTER TABLE … ADD CONSTRAINT … PK … ON ps_AuditLog_Month(OccurredAtUtc)` — whichever EF 10 supports cleanly.)
+ 3. Create the five named indexes via `migrationBuilder.CreateIndex(...)`, partition-aligned on `ps_AuditLog_Month(OccurredAtUtc)` where appropriate.
+ 4. Raw SQL roles, idempotent (`IF NOT EXISTS … CREATE ROLE`):
+ - `scadalink_audit_writer`: GRANT INSERT ON AuditLog; GRANT SELECT ON AuditLog. (No UPDATE, no DELETE.)
+ - `scadalink_audit_purger`: GRANT ALTER ON SCHEMA::dbo; GRANT SELECT ON AuditLog. (Enables ALTER PARTITION FUNCTION SWITCH and SWITCH PARTITION.)
+- `Down()` drops indexes, table, scheme, function, then both roles.
+- Create: `tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs` — uses a fixture connecting to the running `infra/mssql` container via the connection string in `infra/mssql/.env` (or skips with `Skip.If` when the env var `SCADALINK_MSSQL_TEST_CONN` is unset, so CI without the container still passes).
+
+Integration test assertions:
+- `sys.partition_functions` contains `pf_AuditLog_Month`.
+- `sys.partition_schemes` contains `ps_AuditLog_Month`.
+- `INFORMATION_SCHEMA.TABLES` contains `AuditLog` aligned to the partition scheme.
+- `sys.indexes` contains the five expected named indexes.
+- `sys.database_principals` contains both roles.
+- Smoke test: log in as a user mapped to `scadalink_audit_writer`, attempt `UPDATE AuditLog …`, expect `SqlException` with permission error.
+
+**Steps:**
+1. Generate the migration; let EF auto-fill the body.
+2. Failing integration test: assert partition function exists.
+3. Edit migration to add the partition function + scheme + table alignment.
+4. Re-run: pass.
+5. Failing integration test: assert five indexes exist.
+6. Add named indexes to migration.
+7. Failing integration test: assert both roles exist with documented grants.
+8. Add roles to migration.
+9. Failing integration test: smoke `UPDATE AuditLog` as writer expects permission error.
+10. Verify role grants exclude UPDATE.
+11. Run: pass.
+12. Commit: `feat(configdb): add AuditLog migration with monthly partitioning and DB roles (#23)`.
+
+**Notes for the implementer:**
+- Use `Microsoft.Data.SqlClient` directly in the test fixture (not EF) to issue raw SQL for grant assertions.
+- `Skip.If(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SCADALINK_MSSQL_TEST_CONN")), "MSSQL not available")` — keeps tests CI-safe.
+- Test database name: `ScadaLinkAuditMigrationTest_` (created per fixture, dropped on dispose).
+
+**Bundle C acceptance:** Migration applied to a fresh test DB on the `infra/mssql` container creates the partition function/scheme/table/indexes/roles. Smoke test confirms UPDATE is denied for the writer role. All migration tests pass when `SCADALINK_MSSQL_TEST_CONN` is set; skip cleanly when unset.
+
+---
+
+## Bundle D — Repository
+
+### Task 8: IAuditLogRepository + EF implementation + DI
+
+**Files:**
+- Create: `src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs` — three methods:
+ - `Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default);`
+ - `Task> QueryAsync(AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default);`
+ - `Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default);`
+
+ Plus two small DTOs in the same file (or co-located `Types/Audit/`):
+ - `AuditLogQueryFilter` record: nullable `AuditChannel?`, `AuditKind?`, `AuditStatus?`, `string? SourceSiteId`, `string? Target`, `string? Actor`, `Guid? CorrelationId`, `DateTime? FromUtc`, `DateTime? ToUtc`.
+ - `AuditLogPaging` record: `int PageSize`, `Guid? AfterEventId`, `DateTime? AfterOccurredAtUtc` (keyset).
+
+- Create: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — implements all three methods:
+ - `InsertIfNotExistsAsync` uses raw SQL `IF NOT EXISTS (SELECT 1 FROM AuditLog WHERE EventId = @id) INSERT INTO AuditLog …` via `DbContext.Database.ExecuteSqlInterpolatedAsync` (bypasses change tracker).
+ - `QueryAsync` builds an `IQueryable`, applies filters, projects, paged by keyset on `(OccurredAtUtc desc, EventId desc)`.
+ - `SwitchOutPartitionAsync` builds a unique staging table name, runs `CREATE TABLE … ` with identical schema and ON `[PRIMARY]`, runs `ALTER TABLE AuditLog SWITCH PARTITION TO `, then `DROP TABLE `. All inside a single transaction. Computes partition number from `monthBoundary` via `$partition.pf_AuditLog_Month(@boundary)`.
+
+- Modify: `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs` — add `services.AddScoped();` after `INotificationOutboxRepository` line.
+
+- Create: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — uses the same MSSQL fixture from Bundle C (skipped when env var unset) since `InsertIfNotExistsAsync` uses raw SQL that won't run on EF in-memory.
+
+Tests:
+- Insert for fresh `EventId` writes one row.
+- Calling `InsertIfNotExistsAsync` again with the same `EventId` is a no-op (no exception, row count unchanged).
+- `QueryAsync` returns rows in `(OccurredAtUtc desc, EventId desc)` order honoring all filter predicates.
+- `QueryAsync` with non-null `AfterEventId`/`AfterOccurredAtUtc` keysets correctly to the next page.
+- `SwitchOutPartitionAsync` for an old boundary removes the rows belonging to that partition from the live table.
+
+**Steps:**
+1. Failing test: insert + duplicate insert.
+2. Implement using raw SQL.
+3. Failing test: query order + filters.
+4. Implement.
+5. Failing test: keyset paging.
+6. Implement.
+7. Failing test: switch-out partition.
+8. Implement.
+9. Run all: pass.
+10. Commit: `feat(configdb): IAuditLogRepository + EF implementation, append-only with partition-switch purge (#23)`.
+
+**Bundle D acceptance:** Repository tests green. DI smoke test from existing ConfigurationDatabase.Tests still passes.
+
+---
+
+## Bundle E — AuditLog project skeleton + options
+
+### Task 10 (first): Scaffold `src/ScadaLink.AuditLog/` project
+
+**Files:**
+- Create: `src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj` — TargetFramework `net10.0` (matches solution), references `ScadaLink.Commons` + `ScadaLink.ConfigurationDatabase`.
+- Create: `src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs` — `public static class ServiceCollectionExtensions { public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config) { … } }` registering `AuditLogOptions` (from Task 9) and forwarding to `services.AddScoped()` (already registered by ConfigurationDatabase, so this is a no-op but documents the dependency).
+- Create: `tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj` with one smoke test.
+- Create: `tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs` — smoke test: `services.AddAuditLog(config); var p = services.BuildServiceProvider(); Assert.NotNull(p.GetService>());`.
+- Modify: `ScadaLink.slnx` — add both projects.
+
+**Steps:**
+1. `dotnet new classlib -n ScadaLink.AuditLog -o src/ScadaLink.AuditLog --framework net10.0` (then delete the default `Class1.cs`).
+2. `dotnet new xunit -n ScadaLink.AuditLog.Tests -o tests/ScadaLink.AuditLog.Tests --framework net10.0`.
+3. Add `` to Commons + ConfigurationDatabase in the src csproj; add reference to ScadaLink.AuditLog in the test csproj.
+4. Add both projects to `ScadaLink.slnx` (inside the existing `/src/` and `/tests/` folders).
+5. Add `` to the src csproj (already in `Directory.Packages.props`).
+6. Create stub `ServiceCollectionExtensions.AddAuditLog` (just registers options; writer impl comes in M2).
+7. Commit: `feat(auditlog): scaffold ScadaLink.AuditLog project (#23)`.
+
+### Task 9: AuditLogOptions
+
+**Files:**
+- Create: `src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs` — class with:
+ - `int DefaultCapBytes` (default 8192)
+ - `int ErrorCapBytes` (default 65536)
+ - `List HeaderRedactList` (default: `[ "Authorization", "X-Api-Key", "Cookie", "Set-Cookie" ]`)
+ - `List GlobalBodyRedactors` (default: empty)
+ - `Dictionary PerTargetOverrides` (default empty)
+ - `int RetentionDays` (default 365; range [30, 3650])
+- Create: `src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs` — minimal: `int? CapBytes`, `List? AdditionalBodyRedactors`.
+- Create: `src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs` — `IValidateOptions` checking `DefaultCapBytes > 0`, `ErrorCapBytes >= DefaultCapBytes`, `RetentionDays` in `[30, 3650]`.
+- Modify: `src/ScadaLink.AuditLog/ServiceCollectionExtensions.AddAuditLog` to `services.AddOptions().Bind(config.GetSection("AuditLog")).ValidateOnStart(); services.AddSingleton, AuditLogOptionsValidator>();`.
+- Add: `tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs`:
+ - Bind valid section → values present.
+ - Bind invalid `RetentionDays = 0` → validator rejects.
+ - Bind invalid `ErrorCapBytes < DefaultCapBytes` → validator rejects.
+
+**Steps:**
+1. Failing test: valid bind round-trip.
+2. Implement options class.
+3. Failing test: invalid `RetentionDays`.
+4. Implement validator.
+5. Failing test: invalid `ErrorCapBytes`.
+6. Validator covers it.
+7. Run: pass.
+8. Commit: `feat(auditlog): add AuditLogOptions + validator (#23)`.
+
+**Bundle E acceptance:** New `src/ScadaLink.AuditLog/` project builds. Solution still builds. Smoke + options tests green. `ScadaLink.slnx` includes both new entries.
+
+---
+
+## Bundle F — Docs paper trail (controller-direct)
+
+### Task 11: Register AuditLog project in Component-Host.md and confirm README
+
+**Files:**
+- Modify: `docs/requirements/Component-Host.md` — list `ScadaLink.AuditLog` in the central role's registration set.
+- Modify: `README.md` — confirm row #23 link reflects the new project (no functional change unless missing).
+
+This is a 1–3 line edit. Per the cadence memory, controller does it directly without a subagent.
+
+**Commit:** `docs(audit): register ScadaLink.AuditLog project in Host role (#23)`.
+
+---
+
+## Final cross-bundle review
+
+After all bundles ship:
+
+- Dispatch a final code-reviewer subagent over the whole M1 branch.
+- Acceptance gate (from goal prompt step E):
+ - `dotnet test ScadaLink.slnx` green (full solution).
+ - All M1 roadmap acceptance criteria met; each cited by name to the proving test.
+- If green, merge to main `--no-ff` with summary message (step F).
+- Update M2–M8 sections of the roadmap with realities learned (step G), commit.
+- Status paragraph (step H).
+- Proceed to M2 (step I).
diff --git a/docs/plans/2026-05-20-auditlog-m1-foundation.md.tasks.json b/docs/plans/2026-05-20-auditlog-m1-foundation.md.tasks.json
new file mode 100644
index 0000000..de231f1
--- /dev/null
+++ b/docs/plans/2026-05-20-auditlog-m1-foundation.md.tasks.json
@@ -0,0 +1,24 @@
+{
+ "planPath": "docs/plans/2026-05-20-auditlog-m1-foundation.md",
+ "tasks": [
+ {"id": "A1", "subject": "Bundle A T1: Add audit enums (Channel, Kind, Status, ForwardState)", "status": "pending"},
+ {"id": "A2", "subject": "Bundle A T2: Add AuditEvent record", "status": "pending", "blockedBy": ["A1"]},
+ {"id": "A3", "subject": "Bundle A T3: Add IAuditWriter + ICentralAuditWriter", "status": "pending", "blockedBy": ["A2"]},
+ {"id": "A4", "subject": "Bundle A T4: Add audit telemetry + pull message DTOs", "status": "pending", "blockedBy": ["A2"]},
+ {"id": "A-rev", "subject": "Bundle A combined spec+quality review", "status": "pending", "blockedBy": ["A1", "A2", "A3", "A4"]},
+ {"id": "B5", "subject": "Bundle B T5: ScadaLinkDbContext.AuditLogs + IEntityTypeConfiguration with five named indexes", "status": "pending", "blockedBy": ["A-rev"]},
+ {"id": "B-rev", "subject": "Bundle B review", "status": "pending", "blockedBy": ["B5"]},
+ {"id": "C67", "subject": "Bundle C T6+T7: AddAuditLogTable migration (partition fn/scheme/table/indexes) + DB roles, with infra/mssql integration tests", "status": "pending", "blockedBy": ["B-rev"]},
+ {"id": "C-rev", "subject": "Bundle C review", "status": "pending", "blockedBy": ["C67"]},
+ {"id": "D8", "subject": "Bundle D T8: IAuditLogRepository + EF implementation + DI registration", "status": "pending", "blockedBy": ["C-rev"]},
+ {"id": "D-rev", "subject": "Bundle D review", "status": "pending", "blockedBy": ["D8"]},
+ {"id": "E10", "subject": "Bundle E T10: Scaffold src/ScadaLink.AuditLog/ project + slnx entries", "status": "pending", "blockedBy": ["D-rev"]},
+ {"id": "E9", "subject": "Bundle E T9: AuditLogOptions + validator", "status": "pending", "blockedBy": ["E10"]},
+ {"id": "E-rev", "subject": "Bundle E review", "status": "pending", "blockedBy": ["E10", "E9"]},
+ {"id": "F11", "subject": "Bundle F T11 (controller-direct): Register ScadaLink.AuditLog in Component-Host.md + README confirm", "status": "pending", "blockedBy": ["E-rev"]},
+ {"id": "FINAL-rev", "subject": "Final cross-bundle review over the whole M1 branch", "status": "pending", "blockedBy": ["F11"]},
+ {"id": "MERGE", "subject": "Verify gate: full solution dotnet test green, then merge --no-ff to main", "status": "pending", "blockedBy": ["FINAL-rev"]},
+ {"id": "ROADMAP", "subject": "Update downstream M2-M8 sections of roadmap with realities learned in M1", "status": "pending", "blockedBy": ["MERGE"]}
+ ],
+ "lastUpdated": "2026-05-20T00:00:00Z"
+}
diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md
index 13417b9..e3f6ed5 100644
--- a/docs/requirements/Component-AuditLog.md
+++ b/docs/requirements/Component-AuditLog.md
@@ -81,14 +81,14 @@ row per lifecycle event across all channels.
| `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). |
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
-| `Kind` | `varchar(32)` | Channel-specific event kind (see below). |
+| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
| `Actor` | `varchar(128)` NULL | Inbound API: API key name. Outbound: script identity. Central: system user. |
| `Target` | `varchar(256)` NULL | Outbound API: external system + method. DB: connection name. Notification: list name. Inbound API: method name. |
-| `Status` | `varchar(32)` | Outcome of *this event* — `Success`, `TransientFailure`, `PermanentFailure`, `Enqueued`, `Retrying`, `Delivered`, `Parked`, `Discarded`. |
+| `Status` | `varchar(32)` | Outcome of *this event* — `Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`. |
| `HttpStatus` | `int` NULL | HTTP-bearing events only. |
| `DurationMs` | `int` NULL | Call / attempt duration. |
| `ErrorMessage` | `nvarchar(1024)` NULL | Truncated; `ErrorDetail` for full text. |
@@ -107,17 +107,24 @@ row per lifecycle event across all channels.
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X".
- Monthly partitioning on `OccurredAtUtc` from day one; purge is a partition switch (see Retention & Purge).
-**`Kind` values by channel:**
+**`Kind` values (flat — 10 discriminators across all channels):**
-| Channel | Kinds |
+| Kind | Fires when |
|---|---|
-| `ApiOutbound` | `SyncCall`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
-| `DbOutbound` | `SyncWrite`, `SyncRead`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
-| `Notification` | `Enqueued`, `Attempt`, `Terminal` |
-| `ApiInbound` | `Completed` — one row per request, written at request end with final status |
+| `ApiCall` | Sync `ExternalSystem.Call(...)` returns (success or permanent failure). One row per call. |
+| `ApiCallCached` | A cached outbound-API attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
+| `DbWrite` | Sync `Database.Connection().Execute*(...)` / `ExecuteReader(...)` completes. One row per call. |
+| `DbWriteCached` | A cached outbound-DB attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
+| `NotifySend` | Script's `Notify.Send(...)` is enqueued on the site — first row in a notification's lifecycle (`Status=Submitted`). |
+| `NotifyDeliver` | Central Notification Outbox dispatcher records a delivery attempt (`Attempted`) or terminal outcome (`Delivered`/`Parked`/`Discarded`). |
+| `InboundRequest` | An inbound API request completes — one row per request, written at request end with final status. |
+| `InboundAuthFailure` | An inbound API request was rejected at the auth boundary (bad/missing key). One row, `Status=Failed`, `HttpStatus=401`. |
+| `CachedSubmit` | Script-side enqueue of a cached call (`ExternalSystem.CachedCall` / `Database.CachedWrite`); first row in the cached-call lifecycle, written to site SQLite before any forward attempt. |
+| `CachedResolve` | Terminal row for a cached operation — `Status` = `Delivered` / `Failed` / `Parked` / `Discarded`. |
-Inbound API is intentionally collapsed to a single `Completed` row per request
-rather than a multi-event lifecycle.
+Inbound API is intentionally collapsed to a single `InboundRequest` (or
+`InboundAuthFailure` for auth rejections) row per request rather than a
+multi-event lifecycle.
## The Site-Local `AuditLog` (SQLite)
@@ -178,18 +185,24 @@ pattern as Site Call Audit's reconciliation of `SiteCalls`.
### Central direct-write (central-originated events)
Events originating at central never touch site SQLite. Inbound API writes one
-`ApiInbound.Completed` row via `ICentralAuditWriter` synchronously inside the
-request-handler middleware, before the HTTP response is flushed. The
-Notification Outbox dispatcher writes `Notification.Attempt` per delivery
-attempt and `Notification.Terminal` on terminal status. Central direct-writes
-use the same insert-if-not-exists semantics keyed on `EventId`.
+`ApiInbound.InboundRequest` row via `ICentralAuditWriter` synchronously inside
+the request-handler middleware, before the HTTP response is flushed; auth-layer
+rejections emit `ApiInbound.InboundAuthFailure` (`Status=Failed`, HTTP 401)
+instead. The Notification Outbox dispatcher writes
+`Notification.NotifyDeliver` with `Status=Attempted` per delivery attempt and
+`Notification.NotifyDeliver` with `Status=Delivered`/`Parked`/`Discarded` on
+terminal status. Central direct-writes use the same insert-if-not-exists
+semantics keyed on `EventId`.
## Cached Operations — Combined Telemetry
For `ExternalSystem.CachedCall` and `Database.CachedWrite`, the **site** is the
-source of truth for every audit row. The site writes each lifecycle event
-(`CachedEnqueued`, `CachedAttempt`, `CachedTerminal`) to its local SQLite
-`AuditLog` on the hot path (or on the retry tick for `CachedAttempt`), then
+source of truth for every audit row. The site writes each lifecycle event —
+`CachedSubmit` (`Status=Submitted`), then `ApiCallCached`/`DbWriteCached` rows
+for the forward-ack (`Status=Forwarded`) and each retry (`Status=Attempted`),
+then a terminal `CachedResolve` row
+(`Status=Delivered`/`Failed`/`Parked`/`Discarded`) — to its local SQLite
+`AuditLog` on the hot path (or on the retry tick for `Attempted` rows), then
forwards via the same telemetry channel. The telemetry message format gains the
audit-row fields additively — one packet per lifecycle transition carries both
the operational state update AND the audit row content.
@@ -207,7 +220,7 @@ operational `SiteCalls` shape for the dispatcher and UI.
## Payload Capture Policy
- **Default cap** — 8 KB for each of `RequestSummary` and `ResponseSummary`;
- raised to 64 KB on any non-`Success` row.
+ raised to 64 KB on any error row (`Status IN ('Failed', 'Parked', 'Discarded')`).
- **Truncation** — UTF-8 byte-safe; `PayloadTruncated = 1` when applied. Full
bodies are never stored.
- **HTTP headers** — `Authorization`, `Cookie`, `Set-Cookie`, `X-API-Key`, and
@@ -292,7 +305,7 @@ MS SQL for direct-write events). Unredacted secrets never persist.
Point-in-time, computed from the central `AuditLog` table; global and per-site.
- **Audit volume** — events/min landing in the central `AuditLog`; global plus per-site sparkline.
-- **Audit error rate** — % of central `AuditLog` rows with `Status` NOT IN (`Success`, `Delivered`, `Enqueued`) over a rolling 5-minute window. This is the operational error rate of audited operations (HTTP 5xx, transient failures, parked deliveries) — NOT audit-writer health, which surfaces separately via `CentralAuditWriteFailures` and `AuditRedactionFailure`.
+- **Audit error rate** — % of central `AuditLog` rows with `Status IN ('Failed', 'Parked', 'Discarded')` over a rolling 5-minute window. This is the operational error rate of audited operations (HTTP 5xx, permanent failures, parked deliveries) — NOT audit-writer health, which surfaces separately via `CentralAuditWriteFailures` and `AuditRedactionFailure`.
- **Audit backlog** — sum of `Pending` site rows across sites; click drills into a per-site breakdown.
[Notification Outbox](Component-NotificationOutbox.md) and
@@ -350,19 +363,22 @@ global value in v1; per-channel overrides are deferred to v1.x.
## Interactions
- **[External System Gateway (#7)](Component-ExternalSystemGateway.md)** —
- emits `ApiOutbound.SyncCall` rows on every sync `Call()`. For `CachedCall`,
+ emits `ApiOutbound.ApiCall` rows on every sync `Call()`. For `CachedCall`,
emits the combined cached telemetry packet (audit row + operational update)
- per Cached Operations — Combined Telemetry.
-- **[External System Gateway (#7)](Component-ExternalSystemGateway.md) — Database layer** — the database access modes inside ESG emit `DbOutbound.SyncWrite` and `DbOutbound.SyncRead` on script-initiated `Connection()` calls; `Database.CachedWrite` emits the cached-write lifecycle rows via the combined-telemetry packet (same path as `ApiOutbound.Cached*`). Site Runtime is the API surface that exposes the `Database.*` calls to scripts; the audit emission itself lives in ESG.
+ per Cached Operations — Combined Telemetry, using kinds
+ `CachedSubmit` / `ApiCallCached` / `CachedResolve`.
+- **[External System Gateway (#7)](Component-ExternalSystemGateway.md) — Database layer** — the database access modes inside ESG emit `DbOutbound.DbWrite` rows on script-initiated `Connection()` calls (writes and reads share the kind; distinguish via `Extra.rowsAffected` vs `Extra.rowsReturned`); `Database.CachedWrite` emits the cached-write lifecycle rows via the combined-telemetry packet using kinds `CachedSubmit` / `DbWriteCached` / `CachedResolve` (same shape as `ApiOutbound`). Site Runtime is the API surface that exposes the `Database.*` calls to scripts; the audit emission itself lives in ESG.
- **[Inbound API (#14)](Component-InboundAPI.md)** — emits one
- `ApiInbound.Completed` row per request from request-handler middleware,
- written directly to central via `ICentralAuditWriter` before the response is
- flushed.
+ `ApiInbound.InboundRequest` row per successful request from request-handler
+ middleware, written directly to central via `ICentralAuditWriter` before the
+ response is flushed. Auth-layer rejections emit
+ `ApiInbound.InboundAuthFailure` instead (`Status=Failed`, HTTP 401).
- **[Notification Outbox (#21)](Component-NotificationOutbox.md)** — the
- site-emitted `Notification.Enqueued` row flows via audit telemetry; the
- central dispatcher writes `Notification.Attempt` (per delivery attempt) and
- `Notification.Terminal` (on terminal status) directly via
- `ICentralAuditWriter`. The operational `Notifications` table is unchanged.
+ site-emitted `Notification.NotifySend` row (`Status=Submitted`) flows via
+ audit telemetry; the central dispatcher writes `Notification.NotifyDeliver`
+ rows directly via `ICentralAuditWriter` — `Status=Attempted` per delivery
+ attempt, `Status=Delivered`/`Parked`/`Discarded` on terminal status. The
+ operational `Notifications` table is unchanged.
- **[Site Call Audit (#22)](Component-SiteCallAudit.md)** — shares the
cached-call telemetry packet. Central ingest of that packet performs both the
`AuditLog` insert and the `SiteCalls` upsert in one transaction. `SiteCalls`
diff --git a/docs/requirements/Component-Host.md b/docs/requirements/Component-Host.md
index 1196050..bbfaadc 100644
--- a/docs/requirements/Component-Host.md
+++ b/docs/requirements/Component-Host.md
@@ -178,6 +178,7 @@ The Host's `Program.cs` calls these extension methods; the component libraries o
| Communication | Yes | Yes | Yes | Yes | No |
| HealthMonitoring | Yes | Yes | Yes | Yes | No |
| ExternalSystemGateway | Yes | Yes | Yes | Yes | No |
+| AuditLog | Yes | Yes | Yes | Yes | No |
| NotificationService | Yes | No | Yes | Yes | No |
| NotificationOutbox | Yes | No | Yes | Yes | No |
| SiteCallAudit | Yes | No | Yes | Yes | No |
@@ -197,7 +198,7 @@ The Host's `Program.cs` calls these extension methods; the component libraries o
## Dependencies
-- **All 18 component libraries**: The Host references every component project to call their extension methods (excludes CLI, which is a separate executable).
+- **All 19 component libraries**: The Host references every component project to call their extension methods (excludes CLI, which is a separate executable). Audit Log (#23) ships its central+site code in `ScadaLink.AuditLog`; the Host calls `AddAuditLog()` on both roles, M2+ will add `AddAuditLogActors()`.
- **Akka.Hosting**: For `AddAkka()` and the hosting configuration builder.
- **Akka.Remote.Hosting, Akka.Cluster.Hosting**: For Akka subsystem configuration. (No Akka.Persistence plugin — see the Persistence note under REQ-HOST-6.)
- **Serilog.AspNetCore**: For structured logging integration.
diff --git a/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs b/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs
new file mode 100644
index 0000000..89cfe9b
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs
@@ -0,0 +1,36 @@
+namespace ScadaLink.AuditLog.Configuration;
+
+///
+/// Configuration for Audit Log (#23). Bound from the AuditLog section of
+/// appsettings.json. Defaults reflect the design (alog.md §6, §10): an
+/// 8 KiB payload-summary cap, a 64 KiB cap on error rows, and a 365-day central
+/// retention window with monthly partition-switch purge. The default
+/// header-redact list covers HTTP auth headers; per-target overrides extend
+/// (never replace) the global redactor set.
+///
+public sealed class AuditLogOptions
+{
+ /// Default payload-summary cap in bytes (default 8 KiB).
+ public int DefaultCapBytes { get; set; } = 8192;
+
+ /// Payload-summary cap on error rows in bytes (default 64 KiB).
+ public int ErrorCapBytes { get; set; } = 65536;
+
+ /// HTTP headers redacted by default before persistence.
+ public List HeaderRedactList { get; set; } = new()
+ {
+ "Authorization",
+ "X-Api-Key",
+ "Cookie",
+ "Set-Cookie",
+ };
+
+ /// Body-content redactors applied globally (regex patterns).
+ public List GlobalBodyRedactors { get; set; } = new();
+
+ /// Per-target redaction overrides keyed by target identifier.
+ public Dictionary PerTargetOverrides { get; set; } = new();
+
+ /// Central retention window in days (default 365, range [30, 3650]).
+ public int RetentionDays { get; set; } = 365;
+}
diff --git a/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs b/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs
new file mode 100644
index 0000000..59785c3
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs
@@ -0,0 +1,57 @@
+using Microsoft.Extensions.Options;
+
+namespace ScadaLink.AuditLog.Configuration;
+
+///
+/// Validates on startup. The caps drive payload
+/// truncation in the M2+ writers, so an unset/zero cap would let arbitrarily
+/// large blobs into the central AuditLog table.
+/// must be at least as large as
+/// because the error cap is meant to capture more detail than the
+/// happy-path summary, not less. is
+/// bounded to [30, 3650] to keep purge windows sane: too short would
+/// drop in-flight investigations, too long would defeat the partition-switch
+/// purge's purpose.
+///
+public sealed class AuditLogOptionsValidator : IValidateOptions
+{
+ /// Inclusive lower bound for .
+ public const int MinRetentionDays = 30;
+
+ /// Inclusive upper bound for .
+ public const int MaxRetentionDays = 3650;
+
+ ///
+ public ValidateOptionsResult Validate(string? name, AuditLogOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var failures = new List();
+
+ if (options.DefaultCapBytes <= 0)
+ {
+ failures.Add(
+ $"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " +
+ "must be > 0; it drives payload-summary truncation in audit writers.");
+ }
+
+ if (options.ErrorCapBytes < options.DefaultCapBytes)
+ {
+ failures.Add(
+ $"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " +
+ $"must be >= {nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}); " +
+ "the error-row cap is intended to capture more detail than the happy-path summary.");
+ }
+
+ if (options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays)
+ {
+ failures.Add(
+ $"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " +
+ $"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
+ }
+
+ return failures.Count == 0
+ ? ValidateOptionsResult.Success
+ : ValidateOptionsResult.Fail(failures);
+ }
+}
diff --git a/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs b/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs
new file mode 100644
index 0000000..739ee07
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs
@@ -0,0 +1,17 @@
+namespace ScadaLink.AuditLog.Configuration;
+
+///
+/// Per-target redaction override applied additively on top of
+/// and the
+/// /
+/// caps. Targets are identified by the script-facing external-system /
+/// database / notification-list / inbound-API-key name.
+///
+public sealed class PerTargetRedactionOverride
+{
+ /// Optional payload cap override (bytes); null inherits the global cap.
+ public int? CapBytes { get; set; }
+
+ /// Additional body redactor regex patterns (appended to the global list).
+ public List? AdditionalBodyRedactors { get; set; }
+}
diff --git a/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj
new file mode 100644
index 0000000..4999344
--- /dev/null
+++ b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..7e010e6
--- /dev/null
+++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
@@ -0,0 +1,44 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using ScadaLink.AuditLog.Configuration;
+
+namespace ScadaLink.AuditLog;
+
+///
+/// Composition root for the Audit Log (#23) component. M1 registers
+/// and its validator; later milestones extend
+/// this method to wire up writers, telemetry actors, and the central ingest
+/// pipeline. Audit Log (#23) sits alongside Notification Outbox (#21) and
+/// Site Call Audit (#22).
+///
+public static class ServiceCollectionExtensions
+{
+ /// Configuration section bound to .
+ public const string ConfigSectionName = "AuditLog";
+
+ ///
+ /// Binds from the
+ /// section of
+ /// and registers so a misconfigured
+ /// AuditLog section is rejected with a key-naming message when the
+ /// options are first resolved (or at startup when consumers wire in
+ /// ValidateOnStart()). M2+ will register writers, telemetry actors,
+ /// and the central ingest pipeline here. IAuditLogRepository is
+ /// registered by
+ /// ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase,
+ /// so the caller (the Host on the central node) must also call that.
+ ///
+ public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(config);
+
+ services.AddOptions()
+ .Bind(config.GetSection(ConfigSectionName))
+ .ValidateOnStart();
+ services.AddSingleton, AuditLogOptionsValidator>();
+
+ return services;
+ }
+}
diff --git a/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs
new file mode 100644
index 0000000..f5148a3
--- /dev/null
+++ b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs
@@ -0,0 +1,73 @@
+using ScadaLink.Commons.Types.Enums;
+
+namespace ScadaLink.Commons.Entities.Audit;
+
+///
+/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
+/// site rows leave IngestedAtUtc null until ingest. Append-only.
+///
+public sealed record AuditEvent
+{
+ /// Idempotency key; uniquely identifies one audit lifecycle event.
+ public Guid EventId { get; init; }
+
+ /// UTC timestamp when the audited action occurred at its source.
+ public DateTime OccurredAtUtc { get; init; }
+
+ /// UTC timestamp when the row was ingested at central; null on the site hot-path.
+ public DateTime? IngestedAtUtc { get; init; }
+
+ /// Trust-boundary channel the audited action crossed.
+ public AuditChannel Channel { get; init; }
+
+ /// Specific event kind within the channel (see alog.md §4).
+ public AuditKind Kind { get; init; }
+
+ /// Correlation id linking related audit rows (e.g. the cached-op lifecycle).
+ public Guid? CorrelationId { get; init; }
+
+ /// Site id where the action originated; null for central-direct events.
+ public string? SourceSiteId { get; init; }
+
+ /// Instance id where the action originated, when applicable.
+ public string? SourceInstanceId { get; init; }
+
+ /// Script that initiated the action (script trust boundary), when applicable.
+ public string? SourceScript { get; init; }
+
+ /// Authenticated actor for inbound paths (API key name, user, etc.).
+ public string? Actor { get; init; }
+
+ /// Target of the action: external system name, db connection name, list name, or inbound method.
+ public string? Target { get; init; }
+
+ /// Lifecycle status of this row.
+ public AuditStatus Status { get; init; }
+
+ /// HTTP status code where applicable (outbound API + inbound API).
+ public int? HttpStatus { get; init; }
+
+ /// Duration of the audited action in milliseconds, when measurable.
+ public int? DurationMs { get; init; }
+
+ /// Human-readable error summary on failure rows.
+ public string? ErrorMessage { get; init; }
+
+ /// Verbose error detail (stack/exception) on failure rows.
+ public string? ErrorDetail { get; init; }
+
+ /// Truncated/redacted request summary; capped per AuditLogOptions.
+ public string? RequestSummary { get; init; }
+
+ /// Truncated/redacted response summary; capped per AuditLogOptions.
+ public string? ResponseSummary { get; init; }
+
+ /// True when Request/Response summaries were truncated to the payload cap.
+ public bool PayloadTruncated { get; init; }
+
+ /// Free-form JSON extension column for channel-specific extras.
+ public string? Extra { get; init; }
+
+ /// Site-local forwarding state; null on central rows.
+ public AuditForwardState? ForwardState { get; init; }
+}
diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
new file mode 100644
index 0000000..7b15962
--- /dev/null
+++ b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
@@ -0,0 +1,56 @@
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types.Audit;
+
+namespace ScadaLink.Commons.Interfaces.Repositories;
+
+///
+/// Append-only data access for the central AuditLog table (Audit Log #23).
+///
+///
+///
+/// The append-only invariant is enforced both at the SQL level (the
+/// scadalink_audit_writer role has only INSERT + SELECT — UPDATE and DELETE
+/// are not granted) and at the API level: this interface deliberately exposes no
+/// Update and no single-row Delete. Bulk purge is performed exclusively via
+/// monthly partition switch-out ().
+///
+///
+/// Ingest is idempotent on EventId: is
+/// first-write-wins, so retrying telemetry and reconciliation pulls can both feed
+/// the same writer without producing duplicates.
+///
+///
+public interface IAuditLogRepository
+{
+ ///
+ /// Inserts if no row with the same
+ /// exists; otherwise silently leaves the
+ /// stored row untouched (first-write-wins). Bypasses the EF change tracker
+ /// so the row never enters a tracked state.
+ ///
+ Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default);
+
+ ///
+ /// Returns up to rows matching
+ /// , ordered by (OccurredAtUtc DESC, EventId DESC).
+ /// Use keyset paging by passing the last returned row's
+ /// OccurredAtUtc + EventId back via
+ /// +
+ /// to fetch the next page.
+ ///
+ Task> QueryAsync(
+ AuditLogQueryFilter filter,
+ AuditLogPaging paging,
+ CancellationToken ct = default);
+
+ ///
+ /// Switches out (purges) the monthly partition whose lower bound is
+ /// . The honest M1 implementation throws
+ /// : the UX_AuditLog_EventId unique
+ /// index is non-partition-aligned (lives on [PRIMARY], not on
+ /// ps_AuditLog_Month), so SQL Server rejects
+ /// ALTER TABLE … SWITCH PARTITION until the drop-and-rebuild dance
+ /// shipped by the M6 purge actor is in place.
+ ///
+ Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default);
+}
diff --git a/src/ScadaLink.Commons/Interfaces/Services/IAuditWriter.cs b/src/ScadaLink.Commons/Interfaces/Services/IAuditWriter.cs
new file mode 100644
index 0000000..a95bc15
--- /dev/null
+++ b/src/ScadaLink.Commons/Interfaces/Services/IAuditWriter.cs
@@ -0,0 +1,17 @@
+using ScadaLink.Commons.Entities.Audit;
+
+namespace ScadaLink.Commons.Interfaces.Services;
+
+///
+/// Boundary-side abstraction for emitting Audit Log (#23) events.
+/// Implementations on the site write to local SQLite hot-path; on central they write to MS SQL directly.
+/// Failures must NEVER abort the user-facing action.
+///
+public interface IAuditWriter
+{
+ ///
+ /// Persist an audit event. Best-effort: implementations must swallow/log internal failures
+ /// rather than propagating them to the calling boundary code.
+ ///
+ Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
+}
diff --git a/src/ScadaLink.Commons/Interfaces/Services/ICentralAuditWriter.cs b/src/ScadaLink.Commons/Interfaces/Services/ICentralAuditWriter.cs
new file mode 100644
index 0000000..d91f534
--- /dev/null
+++ b/src/ScadaLink.Commons/Interfaces/Services/ICentralAuditWriter.cs
@@ -0,0 +1,16 @@
+using ScadaLink.Commons.Entities.Audit;
+
+namespace ScadaLink.Commons.Interfaces.Services;
+
+///
+/// Central-only audit writer for the direct-write path (Notification Outbox dispatch, Inbound API).
+/// Distinct from so DI binding can differ between site and central hosts.
+///
+public interface ICentralAuditWriter
+{
+ ///
+ /// Persist an audit event into the central AuditLog table directly (bypassing site telemetry).
+ /// Best-effort: implementations must swallow/log internal failures rather than propagating them.
+ ///
+ Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
+}
diff --git a/src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs b/src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs
new file mode 100644
index 0000000..38d7468
--- /dev/null
+++ b/src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs
@@ -0,0 +1,13 @@
+using ScadaLink.Commons.Entities.Audit;
+
+namespace ScadaLink.Commons.Messages.Integration;
+
+///
+/// Audit Log (#23) telemetry envelope sent from a site to central over gRPC.
+/// At-least-once delivery; central is idempotent on .
+/// See Component-AuditLog.md "Ingestion" for the handoff contract.
+///
+public sealed record AuditTelemetryEnvelope(
+ Guid EnvelopeId,
+ string SourceSiteId,
+ IReadOnlyList Events);
diff --git a/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs b/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs
new file mode 100644
index 0000000..df70a30
--- /dev/null
+++ b/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs
@@ -0,0 +1,11 @@
+namespace ScadaLink.Commons.Messages.Integration;
+
+///
+/// Audit Log (#23) periodic reconciliation pull request: central asks a site for
+/// audit events since the given UTC watermark, up to .
+/// Acts as the fallback when streaming telemetry is lost. See Component-AuditLog.md "Ingestion".
+///
+public sealed record PullAuditEventsRequest(
+ string SourceSiteId,
+ DateTime SinceUtc,
+ int BatchSize);
diff --git a/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs b/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs
new file mode 100644
index 0000000..c752304
--- /dev/null
+++ b/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs
@@ -0,0 +1,12 @@
+using ScadaLink.Commons.Entities.Audit;
+
+namespace ScadaLink.Commons.Messages.Integration;
+
+///
+/// Audit Log (#23) periodic reconciliation pull response: the next batch of site
+/// audit events plus a flag signalling the caller
+/// to advance the watermark and pull again. See Component-AuditLog.md "Ingestion".
+///
+public sealed record PullAuditEventsResponse(
+ IReadOnlyList Events,
+ bool MoreAvailable);
diff --git a/src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs b/src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs
new file mode 100644
index 0000000..7feeffd
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs
@@ -0,0 +1,13 @@
+namespace ScadaLink.Commons.Types.Audit;
+
+///
+/// Keyset paging cursor for .
+/// The repository orders by (OccurredAtUtc DESC, EventId DESC); 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 AuditLogPaging(
+ int PageSize,
+ DateTime? AfterOccurredAtUtc = null,
+ Guid? AfterEventId = null);
diff --git a/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs
new file mode 100644
index 0000000..4e2001a
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs
@@ -0,0 +1,21 @@
+using ScadaLink.Commons.Types.Enums;
+
+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.
+///
+public sealed record AuditLogQueryFilter(
+ AuditChannel? Channel = null,
+ AuditKind? Kind = null,
+ AuditStatus? Status = null,
+ string? SourceSiteId = null,
+ string? Target = null,
+ string? Actor = null,
+ Guid? CorrelationId = null,
+ DateTime? FromUtc = null,
+ DateTime? ToUtc = null);
diff --git a/src/ScadaLink.Commons/Types/Enums/AuditChannel.cs b/src/ScadaLink.Commons/Types/Enums/AuditChannel.cs
new file mode 100644
index 0000000..c3b26d3
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/Enums/AuditChannel.cs
@@ -0,0 +1,13 @@
+namespace ScadaLink.Commons.Types.Enums;
+
+///
+/// Top-level Audit Log (#23) channel — the trust boundary the audited action crosses.
+/// One of: outbound API call, outbound DB write, notification send/deliver, or inbound API request.
+///
+public enum AuditChannel
+{
+ ApiOutbound,
+ DbOutbound,
+ Notification,
+ ApiInbound
+}
diff --git a/src/ScadaLink.Commons/Types/Enums/AuditForwardState.cs b/src/ScadaLink.Commons/Types/Enums/AuditForwardState.cs
new file mode 100644
index 0000000..dac799f
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/Enums/AuditForwardState.cs
@@ -0,0 +1,14 @@
+namespace ScadaLink.Commons.Types.Enums;
+
+///
+/// Site-local Audit Log (#23) forwarding state, tracked only in the site SQLite hot-path.
+/// Central rows leave this null. Pending = not yet sent; Forwarded = telemetry sent
+/// and acked; Reconciled = confirmed present centrally via the periodic pull fallback.
+/// The site retention purge MUST NOT drop a row whose state is still Pending.
+///
+public enum AuditForwardState
+{
+ Pending,
+ Forwarded,
+ Reconciled
+}
diff --git a/src/ScadaLink.Commons/Types/Enums/AuditKind.cs b/src/ScadaLink.Commons/Types/Enums/AuditKind.cs
new file mode 100644
index 0000000..faac09f
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/Enums/AuditKind.cs
@@ -0,0 +1,20 @@
+namespace ScadaLink.Commons.Types.Enums;
+
+///
+/// Specific Audit Log (#23) event kind within a channel — what action produced the row.
+/// Cached variants emit multiple rows per operation (submit → forward → attempt → resolve).
+/// See alog.md §4 for the full taxonomy.
+///
+public enum AuditKind
+{
+ ApiCall,
+ ApiCallCached,
+ DbWrite,
+ DbWriteCached,
+ NotifySend,
+ NotifyDeliver,
+ InboundRequest,
+ InboundAuthFailure,
+ CachedSubmit,
+ CachedResolve
+}
diff --git a/src/ScadaLink.Commons/Types/Enums/AuditStatus.cs b/src/ScadaLink.Commons/Types/Enums/AuditStatus.cs
new file mode 100644
index 0000000..fdedb8d
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/Enums/AuditStatus.cs
@@ -0,0 +1,18 @@
+namespace ScadaLink.Commons.Types.Enums;
+
+///
+/// Lifecycle status of an Audit Log (#23) event row.
+/// Cached operations produce multiple rows tracking Submitted → Forwarded → Attempted → Delivered/Parked/Discarded.
+/// Skipped is used when an action was short-circuited (e.g. dry-run) but should still be audited.
+///
+public enum AuditStatus
+{
+ Submitted,
+ Forwarded,
+ Attempted,
+ Delivered,
+ Failed,
+ Parked,
+ Discarded,
+ Skipped
+}
diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs
new file mode 100644
index 0000000..9ad90e0
--- /dev/null
+++ b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs
@@ -0,0 +1,101 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using ScadaLink.Commons.Entities.Audit;
+
+namespace ScadaLink.ConfigurationDatabase.Configurations;
+
+///
+/// Maps the record to the central AuditLog table
+/// described in alog.md §4. Column lengths/types and the five named indexes are
+/// fixed by that specification — keep this in sync with the doc.
+///
+public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("AuditLog");
+
+ // Composite PK includes OccurredAtUtc — required by the monthly partition scheme
+ // (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still
+ // needs to be globally unique for InsertIfNotExistsAsync idempotency, so a
+ // separate unique index is declared on EventId alone.
+ builder.HasKey(e => new { e.EventId, e.OccurredAtUtc });
+
+ builder.HasIndex(e => e.EventId)
+ .IsUnique()
+ .HasDatabaseName("UX_AuditLog_EventId");
+
+ // Enum-as-string columns: bounded varchar(32) ASCII.
+ builder.Property(e => e.Channel)
+ .HasConversion()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .IsRequired();
+
+ builder.Property(e => e.Kind)
+ .HasConversion()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .IsRequired();
+
+ builder.Property(e => e.Status)
+ .HasConversion()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .IsRequired();
+
+ builder.Property(e => e.ForwardState)
+ .HasConversion()
+ .HasMaxLength(32)
+ .IsUnicode(false);
+
+ // Ascii identifier columns — never carry user-supplied unicode.
+ builder.Property(e => e.SourceSiteId)
+ .HasMaxLength(64)
+ .IsUnicode(false);
+
+ builder.Property(e => e.SourceInstanceId)
+ .HasMaxLength(128)
+ .IsUnicode(false);
+
+ builder.Property(e => e.SourceScript)
+ .HasMaxLength(128)
+ .IsUnicode(false);
+
+ builder.Property(e => e.Actor)
+ .HasMaxLength(128)
+ .IsUnicode(false);
+
+ builder.Property(e => e.Target)
+ .HasMaxLength(256)
+ .IsUnicode(false);
+
+ // Bounded unicode message column.
+ builder.Property(e => e.ErrorMessage)
+ .HasMaxLength(1024);
+
+ // ErrorDetail, RequestSummary, ResponseSummary, Extra: leave as nvarchar(max).
+
+ // Indexes — names locked to alog.md §4 for reconciliation/migration discoverability.
+ builder.HasIndex(e => e.OccurredAtUtc)
+ .IsDescending(true)
+ .HasDatabaseName("IX_AuditLog_OccurredAtUtc");
+
+ builder.HasIndex(e => new { e.SourceSiteId, e.OccurredAtUtc })
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_AuditLog_Site_Occurred");
+
+ builder.HasIndex(e => e.CorrelationId)
+ .HasFilter("[CorrelationId] IS NOT NULL")
+ .HasDatabaseName("IX_AuditLog_CorrelationId");
+
+ builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
+ .IsDescending(false, false, true)
+ .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
+
+ builder.HasIndex(e => new { e.Target, e.OccurredAtUtc })
+ .IsDescending(false, true)
+ .HasFilter("[Target] IS NOT NULL")
+ .HasDatabaseName("IX_AuditLog_Target_Occurred");
+ }
+}
diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs
new file mode 100644
index 0000000..0d465d5
--- /dev/null
+++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs
@@ -0,0 +1,1553 @@
+//
+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("20260520142214_AddAuditLogTable")]
+ partial class AddAuditLogTable
+ {
+ ///
+ 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.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