diff --git a/CLAUDE.md b/CLAUDE.md
index cf52da7..5fa1c12 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -123,6 +123,7 @@ each project's **code-verified current state**, and the **gaps** between. See
| UI Theme (layout / tokens / components) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
| Health (readiness / liveness / active-node) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
| Observability (metrics / traces / logs) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
+| Audit (event model + writer seam) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three
@@ -186,6 +187,24 @@ follow-on, tracked in [`components/observability/GAPS.md`](components/observabil
Build/test from `ZB.MOM.WW.Telemetry/`: `dotnet test`. Consumer matrix: all three apps consume both
packages after adoption (OtOpcUa, MxGateway Server, ScadaBridge Host + any instrumented project).
+The audit component is fully populated: a normalized [`spec`](components/audit/spec/SPEC.md), an
+[`event-model`](components/audit/spec/EVENT-MODEL.md) reference, a
+[`shared-contract`](components/audit/shared-contract/ZB.MOM.WW.Audit.md), three
+[`current-state`](components/audit/current-state/) docs, and an adoption [`GAPS`](components/audit/GAPS.md)
+backlog. Common ground = canonical `AuditEvent` record + `AuditOutcome` enum + `IAuditWriter` /
+`IAuditRedactor` seams + helpers (`NullAuditRedactor`, `TruncatingAuditRedactor`, `NoOpAuditWriter`,
+`CompositeAuditWriter`, `RedactingAuditWriter`) + `AddZbAudit` DI registration; left per-project =
+transport/storage and domain vocabulary. Closes the loop on Auth — audit's `Actor` field = the Auth
+principal. `IAuditRedactor` is aligned with Telemetry's `ILogRedactor` seam convention.
+
+The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/)
+(.NET 10; 1 package — `ZB.MOM.WW.Audit`; only non-BCL dependency `Microsoft.Extensions.DependencyInjection.Abstractions`;
+19 tests; `dotnet pack` → 1 nupkg @ 0.1.0). Repo: `https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit`.
+**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/audit/GAPS.md`](components/audit/GAPS.md).
+Build/test from `ZB.MOM.WW.Audit/`: `dotnet test`. Consumer matrix: all three apps consume the single
+`ZB.MOM.WW.Audit` package (OtOpcUa, MxAccessGateway, ScadaBridge each map their own audit record/seam
+onto the canonical type at the emit boundary).
+
## Per-project primary commands
Run these from inside each project directory (not from `scadaproj`).
diff --git a/ZB.MOM.WW.Audit/.gitignore b/ZB.MOM.WW.Audit/.gitignore
new file mode 100644
index 0000000..0808c4a
--- /dev/null
+++ b/ZB.MOM.WW.Audit/.gitignore
@@ -0,0 +1,482 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from `dotnet new gitignore`
+
+# dotenv files
+.env
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+# but not Directory.Build.rsp, as it configures directory-level build defaults
+!Directory.Build.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+.idea/
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# Vim temporary swap files
+*.swp
diff --git a/ZB.MOM.WW.Audit/Directory.Build.props b/ZB.MOM.WW.Audit/Directory.Build.props
new file mode 100644
index 0000000..b234d68
--- /dev/null
+++ b/ZB.MOM.WW.Audit/Directory.Build.props
@@ -0,0 +1,10 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ 0.1.0
+ true
+
+
diff --git a/ZB.MOM.WW.Audit/Directory.Packages.props b/ZB.MOM.WW.Audit/Directory.Packages.props
new file mode 100644
index 0000000..bc720bb
--- /dev/null
+++ b/ZB.MOM.WW.Audit/Directory.Packages.props
@@ -0,0 +1,15 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ZB.MOM.WW.Audit/README.md b/ZB.MOM.WW.Audit/README.md
new file mode 100644
index 0000000..2e1bac5
--- /dev/null
+++ b/ZB.MOM.WW.Audit/README.md
@@ -0,0 +1,59 @@
+# ZB.MOM.WW.Audit
+
+Canonical audit event model, best-effort writer seam, and redactor seam for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). This is a **library, not a service** — it is linked directly into the consuming application at build time. Transport and storage remain per-project; only the shared record + seams live here.
+
+---
+
+## Packages
+
+| Package | Description | Key Dependencies |
+|---|---|---|
+| `ZB.MOM.WW.Audit` | Canonical `AuditEvent` record, `AuditOutcome` enum, `IAuditWriter` + `IAuditRedactor` seams, shipped helpers (`NullAuditRedactor`, `TruncatingAuditRedactor`, `NoOpAuditWriter`, `CompositeAuditWriter`, `RedactingAuditWriter`), and `AddZbAudit` DI extension. | `Microsoft.Extensions.DependencyInjection.Abstractions` |
+
+---
+
+## Consumer Matrix
+
+| Consumer | ZB.MOM.WW.Audit |
+|---|:---:|
+| **OtOpcUa** | yes (adoption deferred) |
+| **MxAccessGateway** | yes (adoption deferred) |
+| **ScadaBridge** | yes (adoption deferred — "align, don't replace") |
+
+Adoption is tracked in `components/audit/GAPS.md` in the outer `scadaproj` workspace. Each app brings its own transport (Akka broadcast / SQLite append / SQL ingest) and domain vocabulary (channels / kinds / event-types) — those stay per-project. The shared library provides the canonical record and the two seams that decouple "what to audit" from "how to store it".
+
+---
+
+## Auth alignment
+
+`AuditEvent.Actor` is a string today. At adoption time it SHOULD be set to the `ZB.MOM.WW.Auth` principal identifier — this is the "audit closes the loop on Auth" hinge described in the spec. No compile-time dependency on `ZB.MOM.WW.Auth` is introduced here; the alignment is by convention.
+
+---
+
+## Versioning
+
+The single package is versioned from `Directory.Build.props`. The current release is **0.1.0**. A single version bump in `Directory.Build.props` bumps the package.
+
+---
+
+## Building and packing
+
+```bash
+# From ZB.MOM.WW.Audit/
+dotnet build ZB.MOM.WW.Audit.slnx
+dotnet test ZB.MOM.WW.Audit.slnx
+
+# Produce the NuGet package into ./artifacts/
+./build/pack.sh
+```
+
+---
+
+## Design documentation
+
+Full design docs live in the `components/audit` folder of the SCADA project workspace:
+
+- `~/Desktop/scadaproj-audit/components/audit/spec/SPEC.md` — overall audit specification
+- `~/Desktop/scadaproj-audit/components/audit/spec/EVENT-MODEL.md` — field-by-field event model + per-project mapping table
+- `~/Desktop/scadaproj-audit/components/audit/shared-contract/ZB.MOM.WW.Audit.md` — public API contract (on paper)
+- `~/Desktop/scadaproj-audit/components/audit/GAPS.md` — adoption backlog
diff --git a/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.slnx b/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.slnx
new file mode 100644
index 0000000..50d2e28
--- /dev/null
+++ b/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.slnx
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/ZB.MOM.WW.Audit/build/pack.sh b/ZB.MOM.WW.Audit/build/pack.sh
new file mode 100755
index 0000000..68d2b48
--- /dev/null
+++ b/ZB.MOM.WW.Audit/build/pack.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+# pack.sh — produce the ZB.MOM.WW.Audit NuGet package into ./artifacts.
+set -euo pipefail
+dotnet pack -c Release -o ./artifacts
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditEvent.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditEvent.cs
new file mode 100644
index 0000000..5de8e2d
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditEvent.cs
@@ -0,0 +1,50 @@
+namespace ZB.MOM.WW.Audit;
+
+///
+/// Canonical, transport-agnostic audit record — who did what, when, with what outcome.
+/// Required core + optional common fields + a extension bag. Each
+/// sister app maps its own record onto this; domain vocabularies (channels/kinds/event-types)
+/// map into // and are not
+/// modelled here. See scadaproj/components/audit/spec/EVENT-MODEL.md.
+///
+public sealed record AuditEvent
+{
+ /// Idempotency key uniquely identifying this audit event.
+ public required Guid EventId { get; init; }
+
+ /// When the audited action occurred. Normalized to UTC on assignment.
+ /// Participates in record value-equality as a normalized instant: two events whose
+ /// OccurredAtUtc denote the same instant at different offsets (e.g. 12:00+05:00 and
+ /// 07:00Z) compare equal and share a hash code. Relevant to consumers that dedup/key on
+ /// value-equality.
+ public required DateTimeOffset OccurredAtUtc
+ {
+ get => _occurredAtUtc;
+ init => _occurredAtUtc = value.ToUniversalTime();
+ }
+ private readonly DateTimeOffset _occurredAtUtc;
+
+ /// Who performed the action (identity string; the ZB.MOM.WW.Auth principal at adoption).
+ public required string Actor { get; init; }
+
+ /// What was done — a verb/event-type string.
+ public required string Action { get; init; }
+
+ /// Normalized outcome.
+ public required AuditOutcome Outcome { get; init; }
+
+ /// Optional subsystem/grouping for the action.
+ public string? Category { get; init; }
+
+ /// Optional target of the action (resource/method/connection).
+ public string? Target { get; init; }
+
+ /// Optional node that emitted the event.
+ public string? SourceNode { get; init; }
+
+ /// Optional correlation id joining this row to its originating request/workflow.
+ public Guid? CorrelationId { get; init; }
+
+ /// Optional JSON extension carrying project-specific fields.
+ public string? DetailsJson { get; init; }
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditOutcome.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditOutcome.cs
new file mode 100644
index 0000000..536561e
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditOutcome.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.Audit;
+
+/// Normalized outcome of an audited action.
+public enum AuditOutcome
+{
+ /// The action completed successfully.
+ Success,
+ /// The action failed due to an error.
+ Failure,
+ /// The action was rejected by authentication/authorization.
+ Denied,
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditServiceCollectionExtensions.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditServiceCollectionExtensions.cs
new file mode 100644
index 0000000..52bc006
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditServiceCollectionExtensions.cs
@@ -0,0 +1,21 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace ZB.MOM.WW.Audit;
+
+/// DI helpers for ZB.MOM.WW.Audit.
+public static class AuditServiceCollectionExtensions
+{
+ ///
+ /// Registers safe defaults — and —
+ /// using TryAdd so a consumer that has already registered a real writer/redactor wins. Consumers
+ /// compose / around their own sink.
+ ///
+ public static IServiceCollection AddZbAudit(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ services.TryAddSingleton(NullAuditRedactor.Instance);
+ services.TryAddSingleton(NoOpAuditWriter.Instance);
+ return services;
+ }
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs
new file mode 100644
index 0000000..6cb271d
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs
@@ -0,0 +1,28 @@
+namespace ZB.MOM.WW.Audit;
+
+/// Fans an event out to several writers. Best-effort: a failing writer does not stop the others.
+/// A failing writer's exception is swallowed so the fan-out drains and the caller is never
+/// aborted — but is re-thrown so cancellation is honored.
+public sealed class CompositeAuditWriter : IAuditWriter
+{
+ private readonly IReadOnlyList _inner;
+
+ /// Creates a composite over the given writers.
+ public CompositeAuditWriter(IEnumerable inner)
+ {
+ ArgumentNullException.ThrowIfNull(inner);
+ _inner = inner.ToArray();
+ }
+
+ ///
+ public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ foreach (var writer in _inner)
+ {
+ try { await writer.WriteAsync(evt, ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { throw; } // honor cancellation; do not swallow
+ catch { /* best-effort seam: a failing writer must not stop the others or the caller */ }
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditRedactor.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditRedactor.cs
new file mode 100644
index 0000000..f8f5f2c
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditRedactor.cs
@@ -0,0 +1,13 @@
+namespace ZB.MOM.WW.Audit;
+
+///
+/// Filters an between construction and persistence — truncates oversized
+/// fields and scrubs sensitive content. Pure function: returns a filtered COPY and MUST NOT throw
+/// (over-redact on internal failure). Shaped to mirror Telemetry's ILogRedactor so a future
+/// ZB.MOM.WW.Hosting aggregator can wire both consistently; intentionally has no dependency on it.
+///
+public interface IAuditRedactor
+{
+ /// Apply the configured truncation/redaction policy and return a filtered copy.
+ AuditEvent Apply(AuditEvent rawEvent);
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditWriter.cs
new file mode 100644
index 0000000..a3e6619
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditWriter.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.Audit;
+
+///
+/// Best-effort sink for s. Implementations MUST swallow/log internal
+/// failures rather than propagating them — a failed audit write must never abort the
+/// user-facing action that produced it.
+///
+public interface IAuditWriter
+{
+ /// Persist an audit event. Best-effort; must not throw to the caller.
+ Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs
new file mode 100644
index 0000000..b737d9f
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.Audit;
+
+/// Writer that discards events. Default when audit is disabled, and useful in tests.
+public sealed class NoOpAuditWriter : IAuditWriter
+{
+ /// Shared singleton instance.
+ public static readonly NoOpAuditWriter Instance = new();
+ private NoOpAuditWriter() { }
+
+ ///
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NullAuditRedactor.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NullAuditRedactor.cs
new file mode 100644
index 0000000..d688b3a
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NullAuditRedactor.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.Audit;
+
+/// Identity redactor — returns the event unchanged. The default when no policy is configured.
+public sealed class NullAuditRedactor : IAuditRedactor
+{
+ /// Shared singleton instance.
+ public static readonly NullAuditRedactor Instance = new();
+ private NullAuditRedactor() { }
+
+ ///
+ public AuditEvent Apply(AuditEvent rawEvent) => rawEvent;
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs
new file mode 100644
index 0000000..4ff4794
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs
@@ -0,0 +1,24 @@
+namespace ZB.MOM.WW.Audit;
+
+/// Decorator: applies an , then delegates to an inner .
+public sealed class RedactingAuditWriter : IAuditWriter
+{
+ private readonly IAuditRedactor _redactor;
+ private readonly IAuditWriter _inner;
+
+ /// Creates the decorator around using .
+ public RedactingAuditWriter(IAuditRedactor redactor, IAuditWriter inner)
+ {
+ ArgumentNullException.ThrowIfNull(redactor);
+ ArgumentNullException.ThrowIfNull(inner);
+ _redactor = redactor;
+ _inner = inner;
+ }
+
+ ///
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ return _inner.WriteAsync(_redactor.Apply(evt), ct);
+ }
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs
new file mode 100644
index 0000000..4ca6cbd
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs
@@ -0,0 +1,41 @@
+namespace ZB.MOM.WW.Audit;
+
+///
+/// Redactor that caps oversized and .
+/// Never throws — over-redacts (drops DetailsJson) on internal failure. The secret-field policy
+/// (which fields are sensitive) stays per-project; compose this with a project redactor as needed.
+///
+public sealed class TruncatingAuditRedactor : IAuditRedactor
+{
+ private readonly TruncatingAuditRedactorOptions _options;
+
+ /// Creates the redactor with the given options (defaults when null).
+ public TruncatingAuditRedactor(TruncatingAuditRedactorOptions? options = null)
+ => _options = options ?? new TruncatingAuditRedactorOptions();
+
+ ///
+ public AuditEvent Apply(AuditEvent rawEvent)
+ {
+ try
+ {
+ return rawEvent with
+ {
+ Target = Truncate(rawEvent.Target, _options.MaxTargetLength),
+ DetailsJson = Truncate(rawEvent.DetailsJson, _options.MaxDetailsJsonLength),
+ };
+ }
+ catch
+ {
+ // Hard contract: never throw. Over-redact on internal failure.
+ return rawEvent with { DetailsJson = null };
+ }
+ }
+
+ private string? Truncate(string? value, int max)
+ {
+ if (value is null || value.Length <= max) return value;
+ var marker = _options.TruncationMarker;
+ if (marker.Length >= max) return marker[..max];
+ return string.Concat(value.AsSpan(0, max - marker.Length), marker);
+ }
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs
new file mode 100644
index 0000000..0c44aba
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.Audit;
+
+/// Caps for .
+public sealed class TruncatingAuditRedactorOptions
+{
+ /// Max length of before truncation. Default 4096.
+ public int MaxDetailsJsonLength { get; set; } = 4096;
+ /// Max length of before truncation. Default 512.
+ public int MaxTargetLength { get; set; } = 512;
+ /// Marker appended to a truncated value. Default "…[truncated]".
+ public string TruncationMarker { get; set; } = "…[truncated]";
+}
diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj
new file mode 100644
index 0000000..7951fde
--- /dev/null
+++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj
@@ -0,0 +1,18 @@
+
+
+ net10.0
+ enable
+ enable
+
+
+ true
+ ZB.MOM.WW.Audit
+ ZB.MOM.WW
+ Canonical audit event model + best-effort writer and redactor seams for the ZB.MOM.WW SCADA family.
+ https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit
+ https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit
+
+
+
+
+
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditEventTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditEventTests.cs
new file mode 100644
index 0000000..f5e17f8
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditEventTests.cs
@@ -0,0 +1,67 @@
+namespace ZB.MOM.WW.Audit.Tests;
+
+public class AuditEventTests
+{
+ private static AuditEvent Minimal() => new()
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "alice",
+ Action = "ConfigPublished",
+ Outcome = AuditOutcome.Success,
+ };
+
+ [Fact]
+ public void Required_core_fields_round_trip()
+ {
+ var id = Guid.NewGuid();
+ var evt = Minimal() with { EventId = id, Actor = "svc", Action = "ApiCall", Outcome = AuditOutcome.Denied };
+ Assert.Equal(id, evt.EventId);
+ Assert.Equal("svc", evt.Actor);
+ Assert.Equal("ApiCall", evt.Action);
+ Assert.Equal(AuditOutcome.Denied, evt.Outcome);
+ }
+
+ [Fact]
+ public void OccurredAtUtc_is_normalized_to_utc()
+ {
+ var local = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.FromHours(5));
+ var evt = Minimal() with { OccurredAtUtc = local };
+ Assert.Equal(TimeSpan.Zero, evt.OccurredAtUtc.Offset);
+ Assert.Equal(local.UtcDateTime, evt.OccurredAtUtc.UtcDateTime);
+ }
+
+ [Fact]
+ public void Optional_fields_default_to_null()
+ {
+ var evt = Minimal();
+ Assert.Null(evt.Category);
+ Assert.Null(evt.Target);
+ Assert.Null(evt.SourceNode);
+ Assert.Null(evt.CorrelationId);
+ Assert.Null(evt.DetailsJson);
+ }
+
+ [Fact]
+ public void Records_with_same_values_are_equal()
+ {
+ var id = Guid.NewGuid();
+ var when = DateTimeOffset.UtcNow;
+ AuditEvent Make() => new() { EventId = id, OccurredAtUtc = when, Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
+ Assert.Equal(Make(), Make());
+ }
+
+ [Fact]
+ public void Same_instant_at_different_offset_compares_equal()
+ {
+ // Guards the UTC-normalizing init-setter: if OccurredAtUtc is ever "simplified" back to a
+ // plain auto-property, these two (same instant, different offset) would stop comparing equal.
+ var id = Guid.NewGuid();
+ var utc = new DateTimeOffset(2026, 6, 1, 7, 0, 0, TimeSpan.Zero);
+ var plus5 = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.FromHours(5)); // same instant as utc
+ AuditEvent With(DateTimeOffset when) =>
+ new() { EventId = id, OccurredAtUtc = when, Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
+ Assert.Equal(With(utc), With(plus5));
+ Assert.Equal(With(utc).GetHashCode(), With(plus5).GetHashCode());
+ }
+}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditServiceCollectionExtensionsTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..d733786
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditServiceCollectionExtensionsTests.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ZB.MOM.WW.Audit.Tests;
+
+public class AuditServiceCollectionExtensionsTests
+{
+ [Fact]
+ public void Registers_null_redactor_and_noop_writer_by_default()
+ {
+ var sp = new ServiceCollection().AddZbAudit().BuildServiceProvider();
+ Assert.IsType(sp.GetRequiredService());
+ Assert.IsType(sp.GetRequiredService());
+ }
+
+ [Fact]
+ public void Does_not_override_a_preregistered_writer()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(new CompositeAuditWriter(System.Array.Empty()));
+ var sp = services.AddZbAudit().BuildServiceProvider();
+ Assert.IsType(sp.GetRequiredService());
+ }
+
+ [Fact]
+ public void Does_not_override_a_preregistered_redactor()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(new TruncatingAuditRedactor());
+ var sp = services.AddZbAudit().BuildServiceProvider();
+ Assert.IsType(sp.GetRequiredService());
+ }
+}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs
new file mode 100644
index 0000000..1faef9c
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs
@@ -0,0 +1,48 @@
+namespace ZB.MOM.WW.Audit.Tests;
+
+public class CompositeAuditWriterTests
+{
+ private sealed class RecordingWriter : IAuditWriter
+ {
+ public int Count;
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Count++; return Task.CompletedTask; }
+ }
+ private sealed class ThrowingWriter : IAuditWriter
+ {
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("boom");
+ }
+ private sealed class CancellingWriter : IAuditWriter
+ {
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new OperationCanceledException();
+ }
+
+ private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
+
+ [Fact]
+ public async Task Fans_out_to_all_writers()
+ {
+ var a = new RecordingWriter(); var b = new RecordingWriter();
+ await new CompositeAuditWriter(new IAuditWriter[] { a, b }).WriteAsync(Evt());
+ Assert.Equal(1, a.Count);
+ Assert.Equal(1, b.Count);
+ }
+
+ [Fact]
+ public async Task One_failing_writer_does_not_stop_the_others()
+ {
+ var after = new RecordingWriter();
+ var sut = new CompositeAuditWriter(new IAuditWriter[] { new ThrowingWriter(), after });
+ await sut.WriteAsync(Evt()); // must not throw
+ Assert.Equal(1, after.Count);
+ }
+
+ [Fact]
+ public async Task Cancellation_is_propagated_not_swallowed()
+ {
+ // OperationCanceledException is re-thrown (unlike ordinary writer failures, which are swallowed).
+ var after = new RecordingWriter();
+ var sut = new CompositeAuditWriter(new IAuditWriter[] { new CancellingWriter(), after });
+ await Assert.ThrowsAsync(() => sut.WriteAsync(Evt()));
+ }
+}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs
new file mode 100644
index 0000000..f44fea1
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.Audit.Tests;
+
+public class NoOpAuditWriterTests
+{
+ [Fact]
+ public async Task WriteAsync_completes_without_error()
+ {
+ var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
+ await NoOpAuditWriter.Instance.WriteAsync(evt);
+ }
+}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs
new file mode 100644
index 0000000..4bca581
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs
@@ -0,0 +1,12 @@
+namespace ZB.MOM.WW.Audit.Tests;
+
+public class NullAuditRedactorTests
+{
+ [Fact]
+ public void Apply_returns_input_unchanged()
+ {
+ var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "{\"k\":1}" };
+ Assert.Same(evt, NullAuditRedactor.Instance.Apply(evt));
+ }
+}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs
new file mode 100644
index 0000000..34dc53d
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs
@@ -0,0 +1,26 @@
+namespace ZB.MOM.WW.Audit.Tests;
+
+public class RedactingAuditWriterTests
+{
+ private sealed class CapturingWriter : IAuditWriter
+ {
+ public AuditEvent? Last;
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Last = evt; return Task.CompletedTask; }
+ }
+ private sealed class StampRedactor : IAuditRedactor
+ {
+ public AuditEvent Apply(AuditEvent rawEvent) => rawEvent with { DetailsJson = "redacted" };
+ }
+
+ private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "secret" };
+
+ [Fact]
+ public async Task Inner_writer_receives_the_redacted_event()
+ {
+ var inner = new CapturingWriter();
+ var sut = new RedactingAuditWriter(new StampRedactor(), inner);
+ await sut.WriteAsync(Evt());
+ Assert.Equal("redacted", inner.Last!.DetailsJson);
+ }
+}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs
new file mode 100644
index 0000000..02ba387
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs
@@ -0,0 +1,56 @@
+namespace ZB.MOM.WW.Audit.Tests;
+
+public class TruncatingAuditRedactorTests
+{
+ private static AuditEvent Evt(string? details, string? target = null) => new()
+ {
+ EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "a", Action = "x", Outcome = AuditOutcome.Success,
+ DetailsJson = details, Target = target,
+ };
+
+ [Fact]
+ public void Short_values_pass_through_unchanged()
+ {
+ var r = new TruncatingAuditRedactor(new() { MaxDetailsJsonLength = 100 });
+ var evt = Evt("small");
+ Assert.Equal("small", r.Apply(evt).DetailsJson);
+ }
+
+ [Fact]
+ public void Oversized_details_are_truncated_with_marker()
+ {
+ var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 10, TruncationMarker = "~" };
+ var r = new TruncatingAuditRedactor(opts);
+ var result = r.Apply(Evt(new string('x', 50)));
+ Assert.Equal(10, result.DetailsJson!.Length);
+ Assert.EndsWith("~", result.DetailsJson);
+ }
+
+ [Fact]
+ public void Oversized_target_is_truncated()
+ {
+ var r = new TruncatingAuditRedactor(new() { MaxTargetLength = 5, TruncationMarker = "" });
+ var result = r.Apply(Evt(null, target: "abcdefghij"));
+ Assert.Equal(5, result.Target!.Length);
+ }
+
+ [Fact]
+ public void Null_fields_are_left_null()
+ {
+ var r = new TruncatingAuditRedactor();
+ var result = r.Apply(Evt(null));
+ Assert.Null(result.DetailsJson);
+ Assert.Null(result.Target);
+ }
+
+ [Fact]
+ public void Marker_longer_than_max_clips_the_marker_itself()
+ {
+ // Misconfiguration: marker longer than the cap. Must not throw; clips to the first max chars.
+ var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 3, TruncationMarker = "…[truncated]" };
+ var r = new TruncatingAuditRedactor(opts);
+ var result = r.Apply(Evt(new string('x', 20)));
+ Assert.Equal(3, result.DetailsJson!.Length);
+ }
+}
diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/ZB.MOM.WW.Audit.Tests.csproj b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/ZB.MOM.WW.Audit.Tests.csproj
new file mode 100644
index 0000000..9ed57d2
--- /dev/null
+++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/ZB.MOM.WW.Audit.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/README.md b/components/README.md
index e09b54c..b8103bd 100644
--- a/components/README.md
+++ b/components/README.md
@@ -21,6 +21,7 @@ specs and analyses that *drive* changes made in the individual repos.
| UI Theme (layout / tokens / components) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Path to shared code (`ZB.MOM.WW.Theme`) | [`ui-theme/`](ui-theme/) |
| Health (readiness / liveness / active-node) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Shared `ZB.MOM.WW.Health` lib (3 packages) | [`health/`](health/) |
| Observability (metrics / traces / logs) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Shared `ZB.MOM.WW.Telemetry` lib (2 packages) | [`observability/`](observability/) |
+| Audit (event model + writer seam) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Path to shared code (`ZB.MOM.WW.Audit`) | [`audit/`](audit/) |
> Add a row when you start normalizing a new component. Status: `Draft` → `Reviewed` → `Adopting` → `Converged`.
diff --git a/components/audit/GAPS.md b/components/audit/GAPS.md
new file mode 100644
index 0000000..cb82e30
--- /dev/null
+++ b/components/audit/GAPS.md
@@ -0,0 +1,114 @@
+# Audit — gaps & adoption backlog
+
+Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
+reach the shared `ZB.MOM.WW.Audit` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
+
+> **Adoption is deferred this round.** The library is being designed (shared contract in
+> [`shared-contract/ZB.MOM.WW.Audit.md`](shared-contract/ZB.MOM.WW.Audit.md)) but is not yet
+> wired into any app — exactly where `ZB.MOM.WW.Auth` and `ZB.MOM.WW.Theme` sit today.
+> The items below are the follow-on work; each lands as a separate PR per project.
+
+## Divergence vs spec
+
+### §1 Canonical record (`AuditEvent`)
+
+| Canonical field | OtOpcUa | MxAccessGateway | ScadaBridge |
+|---|---|---|---|
+| `EventId` (Guid, required) | ✅ — idempotency key; buffer key + filtered-unique DB index | ⛔ — no event key; only an `AUTOINCREMENT` rowid (`AuditId`) | ✅ — direct |
+| `OccurredAtUtc` (DateTimeOffset, required) | 🟡 — `DateTime` UTC; widen at mapping boundary | 🟡 — `DateTimeOffset` but store-assigned (not caller-supplied); direct after widening | 🟡 — `DateTime` UTC-forced; widen at mapping boundary |
+| `Actor` (string, required) | ✅ — direct (`AuditEvent.Actor` → `ConfigAuditLog.Principal`) | 🟡 — `KeyId` nullable; keyless events (`init-db`/`list-keys`) need a `"system"`/`"cli"` fallback | 🟡 — nullable on system-originated rows; fallback needed |
+| `Action` (string, required) | 🟡 — `Action` field exists, but persisted as `"{Category}:{Action}"` composite in `EventType`; canonical keeps them separate | ✅ — `EventType` literal direct | 🟡 — derived as `{Channel}.{Kind}` (e.g. `ApiOutbound.ApiCall`) |
+| `Outcome` (AuditOutcome, required) | ⛔ **NEW** — derived from `EventType` vocabulary; not stored today | ⛔ **NEW** — derived: `constraint-denied`→`Denied`, else `Success` | ⛔ **NEW** — derived from `Status` (+`InboundAuthFailure` Kind→`Denied`) |
+| `Category` (string?) | ✅ — `AuditEvent.Category` (e.g. `"Config"`) | ⛔ — no field; constant `"ApiKey"` at mapping | ✅ — `Channel` |
+| `Target` (string?) | ⛔ — no dedicated field; closest is `DetailsJson` | ⛔ — embedded in `Details` text (`commandKind`/`target`) | ✅ — direct |
+| `SourceNode` (string?) | ✅ — `SourceNode` (logical cluster node / host name, NOT an OPC UA NodeId) | 🟡 — `RemoteAddress`; dashboard path only (null on CLI/constraint paths) | ✅ — direct |
+| `CorrelationId` (Guid?) | ✅ — direct (`CorrelationId.Value`) | ⛔ — not captured today; left null | ✅ — direct |
+| `DetailsJson` (string?) | ✅ — direct (JSON CHECK constraint enforced) | 🟡 — `Details` is a plain string, not JSON; wrap or store as-is | 🟡 — ~15 rich/plumbing fields serialize here at the cross-project reporting boundary |
+
+### §2 `IAuditWriter` seam
+
+| | OtOpcUa | MxAccessGateway | ScadaBridge |
+|---|---|---|---|
+| Named seam | ⛔ — no `IAuditWriter`; `AuditWriterActor` is the sink, consumed directly via Akka messaging | ⛔ — `IApiKeyAuditStore` (narrow, two-method) is the seam; no general `IAuditWriter` | ✅ — `IAuditWriter` with `WriteAsync(AuditEvent, CancellationToken)` signature; "failures must NEVER abort the user-facing action" contract; best-effort |
+| Best-effort / never throws | 🟡 — the actor drops a failed flush (best-effort), but the seam is not a typed interface a caller can inject independently | ⛔ — no contract; `AppendAsync` may propagate | ✅ |
+| Record type at the seam | 🟡 — OtOpcUa's own `AuditEvent` (8 fields, with Commons value-types `NodeId`/`CorrelationId`) | ⛔ — `ApiKeyAuditEntry` (4 fields) | 🟡 — ScadaBridge's ~25-field `AuditEvent` (rich record; adoption = keep own record, adopt canonical interface name + `AuditOutcome`) |
+
+### §3 `IAuditRedactor` seam
+
+| | OtOpcUa | MxAccessGateway | ScadaBridge |
+|---|---|---|---|
+| Named seam | ⛔ — no redactor; no payload filtering today | ⛔ — no redactor; safety by construction (entry type cannot carry a secret) | ✅ — `IAuditPayloadFilter` (`AuditEvent Apply(AuditEvent)`, pure/never-throws/over-redacts); **only the name differs** from canonical `IAuditRedactor` |
+| Over-redacts on failure | ⛔ — n/a | ⛔ — n/a | ✅ — `SafeDefaultAuditPayloadFilter` is the reference |
+
+### §4 `AuditOutcome` — the new normalized field
+
+`Outcome` is a **genuinely new field** across all three projects. No app stores it today;
+each encodes it implicitly. All three must derive and emit it at adoption:
+
+→ **Gap O1 (OtOpcUa):** derive from `EventType` vocabulary — `OpcUaAccessDenied` /
+`CrossClusterNamespaceAttempt` → `Denied`; config-write verbs → `Success`. No `Failure`
+value exists in OtOpcUa's vocabulary today (failed flushes are dropped, not emitted), so
+OtOpcUa will produce only `Success` / `Denied` until/unless failure events are added.
+
+→ **Gap O2 (MxGateway):** derive — `constraint-denied` → `Denied`; all others → `Success`.
+No `Failure` events are emitted today.
+
+→ **Gap O3 (ScadaBridge):** derive from `AuditStatus` — `Delivered` → `Success`;
+`Failed` / `Parked` / `Discarded` → `Failure`; `Kind = InboundAuthFailure` → `Denied`.
+In-flight states (`Submitted` / `Forwarded` / `Attempted`) collapse to the last-known
+terminal state when projecting; `Skipped` is excluded from the canonical projection.
+
+### §5 `Actor` → Auth principal
+
+At adoption, every emit site should supply the `ZB.MOM.WW.Auth` principal as `Actor`
+(string). The library carries no Auth dependency — `Actor` is a plain `string` — but the
+handshake with Auth is the semantic goal (closes the loop).
+
+→ **Gap P1 (all 3):** at adoption, update emit sites to populate `Actor` from the Auth
+principal (LDAP user / API-key name). Auth adoption (#8 in `components/auth/GAPS.md`) is a
+prerequisite for the full story; until then, use the existing actor string.
+
+### §6 OtOpcUa two-producer problem
+
+OtOpcUa has **two writers to `ConfigAuditLog`**: the structured Akka `AuditEvent` path AND
+older SQL stored procedures that `INSERT` directly (bare `EventType`, NULL `EventId` /
+`CorrelationId`, populated `ClusterId` / `GenerationId`). Normalization targets the
+structured path only; the SP path stays per-project.
+
+→ **Gap Q1 (OtOpcUa):** decide at adoption whether to route SP events through the actor
+or leave them non-idempotent. Also: the `ClusterId`-filter / actor-never-sets-`ClusterId`
+mismatch (Admin UI `ClusterAudit.razor` filters by `ClusterId`, but the actor path sets
+`NodeId` not `ClusterId`, so structured rows are invisible to the cluster view). Fix when
+normalizing the query surface.
+
+## Adoption backlog (ordered)
+
+| # | Item | Projects | Priority | Effort | Risk | Notes |
+|---|---|---|---|---|---|---|
+| 1 | **OtOpcUa:** rename `AuditWriterActor` → implements `IAuditWriter`; replace `Commons/Messages/Audit/AuditEvent.cs` with canonical record; add `Outcome` derivation at every emit site (Gap O1) | OtOpcUa | Med | M | Med | Actor internals (batching / dedup / flush triggers) stay bespoke; only the seam type and record change. Commons value-types `NodeId`/`CorrelationId` bridged at construction. |
+| 2 | **MxGateway:** map `IApiKeyAuditStore` / `ApiKeyAuditEntry` / `ApiKeyAuditRecord` → `IAuditWriter` / `AuditEvent`; generate `EventId` per write; add `"system"`/`"cli"` Actor fallback; constant `Category = "ApiKey"`; `constraint-denied`→`Outcome.Denied` (Gaps O2, record gaps) | MxGateway | Low | S | Med | ⚠ **COORDINATE** — a parallel session is editing this repo for the MEL→Serilog migration (Health/Telemetry normalization). Do NOT start until the Serilog session has landed (or is explicitly fenced off); the two efforts share `Security/Authentication/` DI wiring. |
+| 3 | **ScadaBridge:** rename `IAuditPayloadFilter` → `IAuditRedactor` (or alias during transition); adopt canonical `AuditOutcome` enum (Gap O3); confirm writer contract matches (already byte-for-byte) | ScadaBridge | Low | S | High | **"Align, don't replace."** Blast radius is HIGH — `IAuditPayloadFilter` is used across the entire pipeline (site, central, wiring). Rename + alias only; no transport/storage/record change. `DefaultAuditPayloadFilter` / `SafeDefaultAuditPayloadFilter` implementations unchanged. |
+| 4 | **All:** populate `Actor` from `ZB.MOM.WW.Auth` principal at emit sites (Gap P1) | All 3 | Low | S | Low | **Prerequisite:** Auth adoption per `components/auth/GAPS.md` #8. Until Auth is adopted, leave the existing actor string as-is. |
+| 5 | **OtOpcUa:** reconcile two-producer problem — decide SP path routing + fix `ClusterId`-filter / actor mismatch in `ClusterAudit.razor` (Gap Q1) | OtOpcUa | Low | S | Low | Normalization does not unify the SP path; this is a reconcile item to decide and document. The mismatch means structured `AuditEvent` rows are currently invisible to the cluster-scoped view. |
+| 6 | **MxGateway:** add `CorrelationId` capture at constraint denial + dashboard paths; structured `Target` from `Details` text (currently embedded as a plain string in `ConstraintEnforcer`) | MxGateway | Low | S | Low | Nice-to-have parity; not required for adoption. `CorrelationId` and `Target` canonical fields left null until this is done. |
+
+**Sequencing:** #3 (ScadaBridge rename) is lowest-risk and self-contained — do it first (or
+last, depending on blast-radius appetite). #1 (OtOpcUa) is medium effort but independent; it
+can start once the shared library is built. #2 (MxGateway) is the smallest code change but
+has the highest **coordination dependency** — gate it on the Serilog migration landing first.
+#4 (Actor→Auth) is blocked on Auth adoption and is the last to close. #5 and #6 are cleanup
+items with no bearing on shared-library adoption.
+
+Each adoption lands as an opt-in version bump per project behind the seam; the shared library
+is consumed but the bespoke transport/storage/UI for each project is not touched.
+
+## Decisions still open
+
+- ScadaBridge `IAuditPayloadFilter` → `IAuditRedactor`: outright rename vs. transitional alias
+ (both are valid; alias reduces blast radius in the short term).
+- MxGateway `Details` plain string → `DetailsJson`: store as-is or wrap in a JSON object at
+ the mapping boundary.
+- `AuditOutcome` column in OtOpcUa storage: add a new `Outcome` column to `ConfigAuditLog`
+ or fold into `DetailsJson` / derive at read time (schema change vs. runtime cost).
+- OtOpcUa SP path: route through the actor path (unified producer) or leave as a bespoke
+ secondary writer with its own column conventions (separate reconcile effort).
diff --git a/components/audit/README.md b/components/audit/README.md
new file mode 100644
index 0000000..0b5eba5
--- /dev/null
+++ b/components/audit/README.md
@@ -0,0 +1,72 @@
+# Audit (who-did-what)
+
+Status: **Draft**. Normalized component — path to shared code. Goal: converge the three
+sister projects onto a canonical `AuditEvent` record + `AuditOutcome` enum + two thin seams
+(`IAuditWriter`, `IAuditRedactor`), proposed as the `ZB.MOM.WW.Audit` library, while each
+project keeps its own transport, storage, domain vocabulary, and redaction policy.
+
+- The one target: [`spec/SPEC.md`](spec/SPEC.md)
+- Canonical event model + field reference: [`spec/EVENT-MODEL.md`](spec/EVENT-MODEL.md)
+- The proposed shared library: [`shared-contract/ZB.MOM.WW.Audit.md`](shared-contract/ZB.MOM.WW.Audit.md)
+- Divergences + backlog: [`GAPS.md`](GAPS.md)
+- Current state, per project: [`current-state/`](current-state/)
+
+## Why audit is a strong normalization candidate
+
+All three projects record a structured who-did-what trail with an actor identity, an action
+verb, and a timestamp. Two (OtOpcUa + ScadaBridge) already have a named `AuditEvent` record
+with an `EventId` idempotency key, `Actor`, and `CorrelationId`. ScadaBridge already ships
+**both** canonical seams under slightly different names (`IAuditWriter` is byte-for-byte the
+spec; `IAuditPayloadFilter` is the canonical `IAuditRedactor`). OtOpcUa's record is almost
+field-for-field aligned. MxGateway has a narrow API-key-lifecycle log that maps cleanly.
+
+The one new field across all three is `AuditOutcome` — no project stores it explicitly today;
+each encodes it implicitly and derives it at adoption. This is the bulk of the per-project
+work. Transport, storage, domain vocabulary, and redaction policy are **not** unified — each
+project keeps its own bespoke implementation behind the seam.
+
+**Audit closes the loop on Auth.** Every audit row's `Actor` is exactly the identity that the
+`ZB.MOM.WW.Auth` component normalizes (LDAP/GLAuth principal, API-key name). The library keeps
+`Actor` as a plain `string` (no Auth dependency), but at adoption each emit site supplies the
+Auth principal.
+
+**`IAuditRedactor` naming is aligned with Telemetry's `ILogRedactor`** — same shape and naming
+discipline so a future `ZB.MOM.WW.Hosting` aggregator wires both redactors with one mental
+model — but there is no cross-package dependency between the two libraries.
+
+## Status by project
+
+| Project | Audit today | Seams present | `AuditOutcome` | Adoption status |
+|---|---|---|---|---|
+| **OtOpcUa** | Akka cluster-broadcast `AuditEvent` → cluster-singleton `AuditWriterActor` (batch 500/5 s, two-layer dedup) over EF `ConfigAuditLog` (SQL Server). Also a legacy SQL stored-procedure write path (bare `EventType`, NULL `EventId`). Admin UI page `ClusterAudit.razor`. | No named `IAuditWriter` seam; no redactor seam. | Not stored — encoded in `EventType` strings (`OpcUaAccessDenied`/`CrossClusterNamespaceAttempt` → `Denied`; config-write verbs → `Success`). | Not started |
+| **MxAccessGateway** | Single SQLite-backed `IApiKeyAuditStore` / `ApiKeyAuditEntry` — key lifecycle (CLI + dashboard) + constraint denials only. No authn events persisted; no production read consumer. | Narrow custom seam (`IApiKeyAuditStore`); no general `IAuditWriter`; redaction is by-construction (secret never enters the record type). | Not stored — derived: `constraint-denied` → `Denied`; all others → `Success`. | Not started |
+| **ScadaBridge** | Full pipeline: site SQLite hot-path (`SqliteAuditWriter` + ring-buffer fallback) → Akka `ClusterClient` forwarder → central MS SQL (ingest / reconcile / purge / partition maintenance). Rich ~25-field `AuditEvent` record. CLI `export`/`verify-chain`; Blazor audit UI. | ✅ `IAuditWriter` (matches canonical contract word-for-word); ✅ `IAuditPayloadFilter` (= canonical `IAuditRedactor`, identical signature, pure/never-throws/over-redacts). | Not stored explicitly — derived from `Status` (`Delivered`→`Success`; `Failed`/`Parked`/`Discarded`→`Failure`; `Kind = InboundAuthFailure`→`Denied`). | Not started (align, don't replace) |
+
+See each project's `current-state//CURRENT-STATE.md` for code-verified detail and
+adoption plan:
+
+- [`current-state/otopcua/CURRENT-STATE.md`](current-state/otopcua/CURRENT-STATE.md)
+- [`current-state/mxaccessgw/CURRENT-STATE.md`](current-state/mxaccessgw/CURRENT-STATE.md)
+- [`current-state/scadabridge/CURRENT-STATE.md`](current-state/scadabridge/CURRENT-STATE.md)
+
+## Normalized vs. left per-project
+
+**Normalized (the shared `ZB.MOM.WW.Audit` library):** the canonical `AuditEvent` record
+(5 required fields + 4 optional common + `DetailsJson` extension bag); the `AuditOutcome`
+enum (`Success | Failure | Denied`); the `IAuditWriter` seam (best-effort, never throws to
+caller); the `IAuditRedactor` seam (pure, never throws, over-redacts on failure); shipped
+helpers (`NoOpAuditWriter`, `CompositeAuditWriter`, `RedactingAuditWriter`,
+`NullAuditRedactor`, `TruncatingAuditRedactor`). Library has no Akka / EF / SQLite / Serilog
+dependency; its only non-BCL dependency is `Microsoft.Extensions.DependencyInjection.Abstractions`.
+
+**Left per-project (each project keeps these behind the seam):** transport and storage (Akka
+singleton + EF/SQL Server; SQLite; site-SQLite + central MS SQL + forwarding/reconcile
+pipeline); domain vocabulary (`EventType` strings / API-key event-type literals / `Channel` +
+`Kind` + `Status` enums); query, CLI, and UI surfaces (`ClusterAudit.razor`; `ListRecentAsync`;
+`export` / `verify-chain`; Blazor audit pages); redaction *policy* (which fields/payloads are
+sensitive — only the `IAuditRedactor` *seam* is shared).
+
+> **Adoption is deferred this round.** The `ZB.MOM.WW.Audit` library is being designed and
+> the shared contract defined, but none of the three apps wire it in yet — exactly where
+> `ZB.MOM.WW.Auth` and `ZB.MOM.WW.Theme` sit today. The per-project adoption backlog is in
+> [`GAPS.md`](GAPS.md).
diff --git a/components/audit/current-state/mxaccessgw/CURRENT-STATE.md b/components/audit/current-state/mxaccessgw/CURRENT-STATE.md
new file mode 100644
index 0000000..ef3f6c3
--- /dev/null
+++ b/components/audit/current-state/mxaccessgw/CURRENT-STATE.md
@@ -0,0 +1,118 @@
+# Audit — current state: MxAccessGateway (`mxaccessgw`)
+
+Repo: `~/Desktop/MxAccessGateway` (Gitea `mxaccessgw`). Stack: .NET 10 gateway (x64) + x86/net48 worker.
+Audit lives entirely in the **gateway** (.NET 10); the worker records nothing.
+All paths relative to repo root; audit code under `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`. Verified 2026-06-01.
+
+This is the **narrowest** of the three implementations: a single SQLite-backed append-only log scoped
+to **API-key lifecycle and constraint denials**. There is no general-purpose audit abstraction, no
+separate redaction seam, and no CorrelationId. Read-back exists but has no production consumer today.
+
+## How it works today
+
+The audit log is one seam, `IApiKeyAuditStore`
+(`src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs:6`), with exactly two
+operations: `AppendAsync(ApiKeyAuditEntry, ...)` (`IApiKeyAuditStore.cs:14`) and
+`ListRecentAsync(int count, ...)` (`IApiKeyAuditStore.cs:22`). Single implementation,
+`SqliteApiKeyAuditStore` (`SqliteApiKeyAuditStore.cs:5`), registered as a singleton in
+`AuthStoreServiceCollectionExtensions.cs:23` alongside the rest of the auth stores.
+
+- **Append-side shape:** callers pass `ApiKeyAuditEntry(string? KeyId, string EventType, string? RemoteAddress, string? Details)`
+ (`ApiKeyAuditEntry.cs:3`). The store sets the timestamp itself — `AppendAsync` writes
+ `created_utc = DateTimeOffset.UtcNow.ToString("O")` (`SqliteApiKeyAuditStore.cs:20`), so the caller
+ cannot supply the time and there is **no idempotency/event key** (the only identity is the DB
+ `AUTOINCREMENT` rowid).
+- **Read-side shape:** `ListRecentAsync` returns `ApiKeyAuditRecord(long AuditId, string? KeyId, string EventType, string? RemoteAddress, DateTimeOffset CreatedUtc, string? Details)`
+ (`ApiKeyAuditRecord.cs:3`), ordered `audit_id DESC LIMIT $count` (`SqliteApiKeyAuditStore.cs:38-42`),
+ returning `[]` for `count <= 0` (`SqliteApiKeyAuditStore.cs:29-32`).
+- **Storage:** SQLite, the same gateway-owned auth DB (`AuthSqliteConnectionFactory`, WAL; default
+ `C:\ProgramData\MxGateway\gateway-auth.db`). Table `api_key_audit` is created by
+ `SqliteAuthStoreMigrator.cs:95-102` — `audit_id INTEGER PRIMARY KEY AUTOINCREMENT, key_id TEXT NULL,
+ event_type TEXT NOT NULL, remote_address TEXT NULL, created_utc TEXT NOT NULL, details TEXT NULL`,
+ plus index `ix_api_key_audit_key_id_created_utc` (`SqliteAuthStoreMigrator.cs:107-108`). Table name
+ constant `SqliteAuthSchema.ApiKeyAuditTable = "api_key_audit"` (`SqliteAuthSchema.cs:11`). The log is
+ append-only: there is no update/delete/prune path.
+- **Producers (three, all in the gateway):**
+ - **Admin CLI** `ApiKeyAdminCliRunner` — its private `AppendAuditAsync` (`ApiKeyAdminCliRunner.cs:153`)
+ always passes `RemoteAddress: null` (`ApiKeyAdminCliRunner.cs:163`). Event types:
+ `"init-db"` (`:48`), `"create-key"` (`:74`), `"list-keys"` (`:83`),
+ `"revoke-key"` with details `revoked`/`not-found-or-already-revoked` (`:102`),
+ `"rotate-key"` with details `rotated`/`not-found` (`:121`).
+ - **Dashboard** `DashboardApiKeyManagementService` — its `AppendAuditAsync` (`:197`) captures
+ `RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString()` (`:207`).
+ Event types: `"dashboard-create-key"` (`:62`), `"dashboard-revoke-key"` (`:103`, details
+ `revoked`/`not-found-or-already-revoked`), `"dashboard-rotate-key"` (`:145`, details `rotated`/`not-found`),
+ `"dashboard-delete-key"` (`:187`, details `deleted`/`not-found-or-active`).
+ - **Constraint denials** `ConstraintEnforcer.RecordDenialAsync` (`ConstraintEnforcer.cs:117`) writes
+ `EventType: "constraint-denied"`, `RemoteAddress: null`, and `Details:
+ $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"` (`ConstraintEnforcer.cs:124-129`).
+ This is the only "denial" event in the log.
+- **No authn events.** The verifier (`ApiKeyVerifier`) and the gRPC authorization interceptor
+ (`GatewayGrpcAuthorizationInterceptor`) do **not** write to the audit store — authentication
+ success/failure and `Unauthenticated`/`PermissionDenied` outcomes are surfaced as gRPC statuses and
+ (per policy) discriminated for logging, but are not persisted as audit rows. So in practice the log
+ records **key lifecycle (CLI + dashboard) + constraint denials**, not per-request authn outcomes.
+- **No separate redaction seam — scrubbing is structural, in the store/entry shape.** There is no
+ redactor, scrubber, sanitizer, or masking helper. Safety comes from *what the entry type can carry*:
+ `ApiKeyAuditEntry` has no field for a secret, and every caller passes only a `KeyId` (the public
+ key identifier, never the secret), an event-type literal, and short hand-built `Details` strings —
+ the secret/pepper never enters the audit path. This aligns with the repo policy that "API keys,
+ passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs"
+ (`CLAUDE.md:79`). Net: redaction is by construction, not a pluggable seam.
+- **Read-back has no production consumer.** `ListRecentAsync` is called only by tests
+ (`SqliteAuthStoreTests`, `ApiKeyAdminCliRunnerTests`). The dashboard `ApiKeysPage.razor` mentions the
+ audit log only in a delete-confirmation string (`ApiKeysPage.razor:321`) — it does **not** render it.
+ There is no UI or RPC that surfaces audit history today.
+
+## Mapping to the canonical record
+
+Target: `ZB.MOM.WW.Audit`'s `AuditEvent { Guid EventId; DateTimeOffset OccurredAtUtc; string Actor;
+string Action; AuditOutcome Outcome; string? Category; string? Target; string? SourceNode;
+Guid? CorrelationId; string? DetailsJson; }` with `AuditOutcome ∈ { Success, Failure, Denied }`.
+
+| `AuditEvent` field | Source today | Mapping note |
+|---|---|---|
+| `EventId` (Guid, required) | — none — | **Must be generated** at write time. `ApiKeyAuditRecord` has only the autoincrement `AuditId` (`ApiKeyAuditRecord.cs:4`); no idempotency key exists. |
+| `OccurredAtUtc` (required) | `CreatedUtc` (`ApiKeyAuditRecord.cs:8`), set as `DateTimeOffset.UtcNow` in the store (`SqliteApiKeyAuditStore.cs:20`) | Direct. Note: time is store-assigned today, not caller-supplied. |
+| `Actor` (required) | `KeyId` (`ApiKeyAuditRecord.cs:5`) | Nullable today (`init-db`/`list-keys` pass `null`); the canonical `Actor` is required, so a fallback (e.g. `"system"`/`"cli"`) is needed for keyless events. |
+| `Action` (required) | `EventType` (`ApiKeyAuditRecord.cs:6`) | Direct. CLI vocab: `init-db`, `create-key`, `list-keys`, `revoke-key`, `rotate-key`; dashboard vocab: `dashboard-create-key`, `dashboard-revoke-key`, `dashboard-rotate-key`, `dashboard-delete-key`; plus `constraint-denied`. |
+| `Outcome` (required) | derived | `constraint-denied` → `Denied`; everything else → `Success` (no `Failure` events are emitted today). |
+| `Category` | — none — | Constant `"ApiKey"`. |
+| `Target` | — none as a field — | No structured target. (`ConstraintEnforcer` does embed `commandKind`/`target` inside `Details` text, but there is no dedicated column.) |
+| `SourceNode` | `RemoteAddress` (`ApiKeyAuditRecord.cs:7`) | Direct; populated only on the dashboard path (`DashboardApiKeyManagementService.cs:207`), `null` on CLI/constraint paths. |
+| `CorrelationId` | — none — | Not captured today. |
+| `DetailsJson` | `Details` (`ApiKeyAuditRecord.cs:9`) | Today this is a **plain string**, not JSON; either store as-is in `DetailsJson` or wrap as a small JSON object. |
+
+---
+
+## Adoption plan → `ZB.MOM.WW.Audit`
+
+**Effort: LOW.** The seam is tiny (one interface, two methods, one record pair) and the data already
+maps cleanly onto `AuditEvent`. Concretely:
+
+1. **Adapter, not rewrite.** Map `IApiKeyAuditStore` → the shared `IAuditWriter`, and
+ `ApiKeyAuditEntry`/`ApiKeyAuditRecord` → `AuditEvent`, using the table above: generate a new
+ `EventId` Guid per write; `KeyId → Actor` (with a `"system"` fallback for null); `EventType → Action`;
+ `CreatedUtc → OccurredAtUtc`; `RemoteAddress → SourceNode`; `constraint-denied → Outcome.Denied`,
+ else `Success`; constant `Category = "ApiKey"`; `Details → DetailsJson`. The three producers
+ (`ApiKeyAdminCliRunner`, `DashboardApiKeyManagementService`, `ConstraintEnforcer`) keep their call
+ sites — only the injected type changes.
+2. **Redaction stays by-construction.** No separate redactor needs porting; just preserve the rule that
+ callers never put secrets in `DetailsJson` (mirrors `CLAUDE.md:79`). The shared writer can keep its
+ own redaction policy as a defence-in-depth layer.
+3. **Read-back is free to drop or defer.** `ListRecentAsync` has no production consumer, so the adapter
+ need not implement a shared query API on day one — only the test/CLI read paths exercise it.
+4. **No new dimensions required.** `CorrelationId` and a structured `Target` are absent today and are
+ *not* in scope to add as part of adoption (descriptive parity only); the canonical record simply
+ leaves them `null`.
+
+**Coordination risk — sequence against the health/observability work.** A parallel session is actively
+editing **this same repo** (`mxaccessgw`) for the MEL → Serilog logging migration
+(`ZB.MOM.WW.Health` + `ZB.MOM.WW.Telemetry` normalization). Because audit adoption here also touches the
+gateway's `Security/Authentication/` wiring (DI registration in `AuthStoreServiceCollectionExtensions.cs`,
+and the three producer call sites), the two efforts can collide on the same files and on logging-pipeline
+DI. **Do not start MxGateway audit adoption until the Serilog migration in this repo has landed (or is
+explicitly fenced off)**, and confirm with the orchestrator that the logging session is not mid-flight in
+`Security/` before opening a PR. The audit and logging seams are conceptually independent (audit = durable
+SQLite record of who-did-what; logging = operational telemetry), but they share the gateway's startup/DI
+surface, so they must be merged in a defined order rather than in parallel.
diff --git a/components/audit/current-state/otopcua/CURRENT-STATE.md b/components/audit/current-state/otopcua/CURRENT-STATE.md
new file mode 100644
index 0000000..0fbde6e
--- /dev/null
+++ b/components/audit/current-state/otopcua/CURRENT-STATE.md
@@ -0,0 +1,140 @@
+# Audit — current state: OtOpcUa
+
+Repo: `~/Desktop/OtOpcUa` (Gitea `lmxopcua`). Stack: .NET 10, Akka.NET cluster, EF Core + SQL Server.
+All paths below are relative to the repo root. Verified against source on 2026-06-01.
+
+OtOpcUa already has a structured, idempotent audit pipeline: a cluster-broadcast `AuditEvent`
+message, a cluster-singleton writer actor that batches and bulk-inserts, and an append-only
+`ConfigAuditLog` EF entity with two-layer dedup. There is **also** a second, older write path —
+SQL stored procedures that `INSERT dbo.ConfigAuditLog` directly — so the table has two
+producers with slightly different column conventions (see §1).
+
+## 1. How it works today
+
+**Record shape** — `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs:9-17`:
+a sealed record `AuditEvent(Guid EventId, string Category, string Action, string Actor,
+DateTime OccurredAtUtc, string? DetailsJson, NodeId SourceNode, CorrelationId CorrelationId)`.
+`NodeId` and `CorrelationId` are Commons value-types — `NodeId` wraps a string (the *logical
+cluster node / host name*, explicitly **not** an OPC UA NodeId per its XML doc,
+`src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/NodeId.cs:3-8`); `CorrelationId` wraps a `Guid`
+(`src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/CorrelationId.cs:3`).
+
+**Transport** — `AuditEvent` is an Akka message meant to be sent to the `AuditWriterActor`
+**cluster singleton** (`AuditEvent.cs:6` describes it as "cluster-broadcast … consumed by the
+`AuditWriterActor` singleton"). The singleton is registered through Akka.Hosting at
+`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs:68-75`
+(`WithSingleton(AuditWriterSingletonName, …)`). Any cluster member can
+emit an `AuditEvent`; the singleton is the one sink that persists it.
+
+**Storage** — EF entity `ConfigAuditLog`
+(`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs:7-44`): append-only
+("Grants revoked for UPDATE/DELETE on all principals", `ConfigAuditLog.cs:4-5`). Columns:
+`AuditId` (identity PK), `Timestamp` (default `SYSUTCDATETIME()`), `Principal`, `EventType`,
+`ClusterId?`, `NodeId?`, `GenerationId?`, `DetailsJson?`, `EventId?` (Guid), `CorrelationId?`
+(Guid). Mapping/constraints in `OtOpcUaConfigDbContext.cs:429-463`: `DetailsJson` must be valid
+JSON (`CK_ConfigAuditLog_DetailsJson_IsJson`, line 435-436); `Principal`/`EventType`/`ClusterId`/`NodeId`
+length-capped (lines 441-444); supporting indexes `IX_ConfigAuditLog_Cluster_Time` (line 449-451)
+and `IX_ConfigAuditLog_Generation` (line 452-454).
+
+**Writer / batching** — `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs`:
+a `ReceiveActor` with `FlushBatchSize = 500` (line 25) and `FlushInterval = 5s` (line 26).
+It buffers events in a `Dictionary` keyed by `EventId` (line 30), flushing
+when the buffer hits 500 (line 60), when the 5s periodic timer fires (`PreStart`, line 50-53),
+or on `PreRestart`/`PostStop` (lines 96-107) so a supervisor swap or coordinated shutdown does
+not lose the buffer. `FlushBuffer` (lines 63-93) snapshots and clears the buffer, then for each
+event constructs a `ConfigAuditLog` row (lines 75-84): `Timestamp = OccurredAtUtc`,
+`Principal = Actor`, `EventType = $"{Category}:{Action}"`, `NodeId = SourceNode.Value`,
+`DetailsJson`, `EventId`, `CorrelationId = CorrelationId.Value`. A failed flush is logged and the
+batch is **dropped** (`catch` at lines 89-92) — best-effort, no retry/dead-letter.
+
+**Dedup / idempotency (two layers)** — described at `AuditWriterActor.cs:17-21`:
+1. *In-buffer* — duplicate `EventId`s within a batch collapse via the dictionary (last-write-wins;
+ `HandleEvent`, lines 55-61).
+2. *Database* — a **filtered unique index** `UX_ConfigAuditLog_EventId` (`OtOpcUaConfigDbContext.cs:459-462`,
+ `IsUnique()` + `HasFilter("[EventId] IS NOT NULL")`) gives cross-restart safety: a retry of an
+ already-flushed batch hits the constraint, the duplicate insert is dropped, and the rest of the
+ batch survives. `EventId`/`CorrelationId` are nullable so legacy/backfill rows (NULL) don't
+ collide — confirmed in the entity XML (`ConfigAuditLog.cs:33-43`) and migration
+ `Migrations/20260526105027_AddConfigAuditLogEventIdColumns.cs:26-31`.
+
+**Scope** — two producers, two conventions:
+- **Akka `AuditEvent` path** (the structured one): config writes + authorization checks. The
+ EventType vocabulary lives in the entity XML doc (`ConfigAuditLog.cs:18`): `DraftCreated |
+ DraftEdited | Published | RolledBack | NodeApplied | CredentialAdded | CredentialDisabled |
+ ClusterCreated | NodeAdded | ExternalIdReleased | CrossClusterNamespaceAttempt |
+ OpcUaAccessDenied | …`. Note the access-denied / cross-cluster entries are authz-check events,
+ not config writes.
+- **SQL stored-procedure path** (older, still present): several SPs `INSERT dbo.ConfigAuditLog`
+ directly — e.g. `Published`/`RolledBack`/`NodeApplied`/`ExternalIdReleased`/`CrossClusterNamespaceAttempt`
+ in `Migrations/20260417215224_StoredProcedures.cs:151,217,351,407,504`. These use `SUSER_SNAME()`
+ as `Principal`, set `ClusterId`/`GenerationId`, write a **bare** `EventType` (no `Category:Action`
+ split), and leave `EventId`/`CorrelationId` NULL.
+
+**Query / UI** — the only read surface is the Admin UI page
+`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor`
+(`@page "/clusters/{ClusterId}/audit"`, `[Authorize]`, lines 1-2). It reads the latest
+`PageSize = 200` rows (line 69) **filtered by `ClusterId`**, newest-first (`OnInitializedAsync`,
+lines 74-82), and renders Timestamp / Principal / Event(Type) / Node / Correlation(first 8 hex) /
+Details columns (lines 38-58). Tested in
+`tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs`: count-threshold
+flush (lines 26-41), in-buffer dedup of duplicate EventIds (lines 45-62), `PostStop` flush
+(lines 66-81), and the column mapping incl. `EventType == "Config:Edit"` and `NodeId == "node-a"`
+(lines 85-104).
+
+> Load-bearing gotcha: the actor path **never sets `ClusterId`** (lines 75-84), but the UI filters
+> on `ClusterId` (`ClusterAudit.razor:78`). So today the cluster-scoped view surfaces the
+> stored-procedure rows; structured `AuditEvent` rows written by the actor (which carry the host in
+> `NodeId`, not `ClusterId`) won't appear under a cluster. Worth flagging during normalization.
+
+## 2. Mapping to the canonical `AuditEvent`
+
+Target = `ZB.MOM.WW.Audit.AuditEvent` (built in parallel). OtOpcUa's existing `AuditEvent` is
+already almost field-for-field aligned; the only synthesized field is `Outcome`.
+
+| Canonical field | OtOpcUa source | Mapping |
+|---|---|---|
+| `Guid EventId` | `AuditEvent.EventId` | Direct. Already the idempotency key (buffer key + `UX_ConfigAuditLog_EventId`). |
+| `DateTimeOffset OccurredAtUtc` | `AuditEvent.OccurredAtUtc` (`DateTime`) | Direct; widen `DateTime`(UTC) → `DateTimeOffset`. |
+| `string Actor` | `AuditEvent.Actor` | Direct (→ `ConfigAuditLog.Principal`). At Auth adoption this becomes the `ZB.MOM.WW.Auth` principal. |
+| `string Action` | `AuditEvent.Action` (+ `Category`) | Direct. Today persisted as `"{Category}:{Action}"` in `EventType`; canonical keeps `Action` and `Category` separate. |
+| `AuditOutcome Outcome` | *(none)* | **Derived** from the EventType vocabulary, not stored today. `OpcUaAccessDenied`/`CrossClusterNamespaceAttempt` → `Denied`; the config-write verbs → `Success`. No explicit `Failure` value exists yet (a failed flush is dropped, not recorded as an event). |
+| `string? Category` | `AuditEvent.Category` | Direct (e.g. `"Config"`). |
+| `string? Target` | *(none)* | No dedicated field today; the closest is `SourceNode`→`NodeId` (the acting host) or details. Leave null or carry the affected object in `DetailsJson`. |
+| `string? SourceNode` | `AuditEvent.SourceNode` (`NodeId.Value`) | Direct — the logical cluster node / host name (NOT an OPC UA NodeId). Currently lands in `ConfigAuditLog.NodeId`. |
+| `Guid? CorrelationId` | `AuditEvent.CorrelationId` (`CorrelationId.Value`) | Direct. |
+| `string? DetailsJson` | `AuditEvent.DetailsJson` | Direct; carries everything else (incl. `ClusterId`/`GenerationId`, which today are separate columns on the SP path). |
+
+## 3. Adoption plan → `ZB.MOM.WW.Audit`
+
+**Effort: medium.** OtOpcUa is the *donor* design for the canonical record, so most of the work is
+re-pointing types and bridging two persistence conventions, not redesigning the pipeline.
+
+**Replace with the shared library:**
+- `Commons/Messages/Audit/AuditEvent.cs` → the canonical `ZB.MOM.WW.Audit.AuditEvent`. Add the new
+ `Outcome` field (derive it at every emit site from the EventType vocabulary, e.g.
+ `OpcUaAccessDenied → Denied`); keep `Category`/`Action`/`SourceNode`/`CorrelationId` as-is. Decide
+ whether `SourceNode`/`CorrelationId` carry the Commons value-types or the canonical primitives at
+ the seam (likely a thin adapter at construction).
+- `AuditWriterActor` → implement the library's `IAuditWriter` (keep the actor as OtOpcUa's
+ Akka-cluster-singleton transport/batching adapter behind that seam; the 500/5s batching,
+ PreRestart/PostStop flush, and two-layer dedup stay bespoke per §"left per-project").
+
+**Keep bespoke (thin adapter only):**
+- Transport — the cluster-broadcast → singleton `AuditWriterActor`, batching, and flush triggers.
+- Storage — the `ConfigAuditLog` EF entity, indexes, and `UX_ConfigAuditLog_EventId` idempotency
+ index. Map the canonical record onto the existing columns; add an `Outcome` column (or fold it into
+ `EventType`/`DetailsJson` if a schema change is undesirable). `ClusterId`/`GenerationId` remain
+ OtOpcUa-specific columns fed via `DetailsJson` or kept as side columns.
+- Domain vocabulary — the EventType strings (`DraftCreated`, `Published`, `OpcUaAccessDenied`, …)
+ and the `Category:Action` composition convention.
+- Query/UI — `ClusterAudit.razor` and its `ClusterId` filter.
+
+**Reconcile, not extract:**
+- The **two producers** (Akka `AuditEvent` path vs. SQL stored-procedure `INSERT`s using
+ `SUSER_SNAME()`). The SP path bypasses the canonical record entirely and writes a different
+ column convention (bare `EventType`, NULL `EventId`/`CorrelationId`, populated
+ `ClusterId`/`GenerationId`). Adopting the library does not by itself unify these; either route the
+ SP events through the actor or accept that SP rows stay non-idempotent and absent from the
+ `EventId` dedup guarantee. Flag for the normalization spec.
+- The **`ClusterId`-filter / actor-never-sets-`ClusterId`** mismatch noted in §1 — fix when the
+ query surface is normalized so structured `AuditEvent` rows are discoverable by cluster.
diff --git a/components/audit/current-state/scadabridge/CURRENT-STATE.md b/components/audit/current-state/scadabridge/CURRENT-STATE.md
new file mode 100644
index 0000000..3f33b5a
--- /dev/null
+++ b/components/audit/current-state/scadabridge/CURRENT-STATE.md
@@ -0,0 +1,162 @@
+# Audit — current state: ScadaBridge
+
+Repo: `~/Desktop/ScadaBridge`. Stack: .NET 10, Akka.NET; solution `ZB.MOM.WW.ScadaBridge.slnx`.
+Audit code centers on the dedicated `ZB.MOM.WW.ScadaBridge.AuditLog` project, with the shared
+record + seams living in `ZB.MOM.WW.ScadaBridge.Commons`. All paths relative to repo root.
+Verified 2026-06-01.
+
+**By far the largest audit implementation in the family** — a full who-did-what pipeline
+across a site SQLite hot-path and a central MS SQL store, with forwarding, reconciliation,
+purge, partition maintenance, redaction, CLI export, hash-chain verify (v1 stub), and a Blazor
+UI. **Key finding: ScadaBridge is already at the target.** It already has an `IAuditWriter`
+best-effort seam (near-identical to the canonical contract) and an `IAuditPayloadFilter`
+redaction seam (= the library's `IAuditRedactor`, just renamed). Adoption is *align, don't
+replace* — mostly naming alignment; the enormous transport/storage/CLI/UI stays bespoke.
+
+## 1. How it works today
+
+### The record — `AuditEvent` (~25 fields)
+
+`src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditEvent.cs:22` — a `sealed record`,
+append-only, "single source of truth for AuditLog (#23) rows." Far richer than the canonical
+10-field event. Notable fields:
+
+- Identity / correlation: `EventId` (idempotency key, `:25`), `CorrelationId` (per-op
+ lifecycle, `:68`), `ExecutionId` (per-run, `:75`), `ParentExecutionId` (spawner link, `:82`).
+- Classification: `Channel` (`:62`), `Kind` (`:65`), `Status` (`:109`) — the domain enums (below).
+- Provenance: `SourceSiteId` (`:85`), `SourceNode` (`:94`, stamped from `INodeIdentityProvider`),
+ `SourceInstanceId` (`:97`), `SourceScript` (`:100`), `Actor` (`:103`), `Target` (`:106`).
+- Outcome detail: `HttpStatus` (`:112`), `DurationMs` (`:115`), `ErrorMessage` (`:118`),
+ `ErrorDetail` (`:121`).
+- Payload: `RequestSummary` / `ResponseSummary` (truncated+redacted, `:124`/`:127`),
+ `PayloadTruncated` (`:130`), `Extra` (free-form JSON, `:133`).
+- Lifecycle plumbing: `IngestedAtUtc` (null on site, stamped at central ingest, `:52`),
+ `ForwardState` (site-only, null on central, `:136`).
+
+**UTC-forcing init-setters.** `OccurredAtUtc` (`:39`) and `IngestedAtUtc` (`:52`) keep a backing
+field and call `DateTime.SpecifyKind(value, DateTimeKind.Utc)` on assignment, so a value built
+from a literal or rehydrated from a SQL Server `datetime2` column (which strips `Kind` on the
+wire) cannot leak downstream as `Unspecified`/local. The record uses `DateTime` (not
+`DateTimeOffset`) deliberately, to match the partitioned `datetime2` column shape (`:9-21`).
+
+### Domain vocabulary — four enums
+
+`src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/`:
+
+- `AuditChannel.cs:7` — trust boundary crossed: `ApiOutbound`, `DbOutbound`, `Notification`,
+ `ApiInbound`.
+- `AuditKind.cs:8` — specific event within a channel: `ApiCall`, `ApiCallCached`, `DbWrite`,
+ `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`,
+ `CachedSubmit`, `CachedResolve`. Cached variants emit multiple rows per operation.
+- `AuditStatus.cs:8` — lifecycle status of the row: `Submitted`, `Forwarded`, `Attempted`,
+ `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`.
+- `AuditForwardState.cs:9` — site-local forwarding state (central rows leave null): `Pending`,
+ `Forwarded`, `Reconciled`. The site retention purge MUST NOT drop a `Pending` row.
+
+### The writer seam — `IAuditWriter` (best-effort, never aborts the action)
+
+`src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/IAuditWriter.cs:10` — boundary-side
+abstraction: `Task WriteAsync(AuditEvent evt, CancellationToken ct = default)` (`:18`). The
+contract is explicit and matches the canonical seam almost word-for-word: **"Failures must NEVER
+abort the user-facing action"** (`:8`), best-effort, "implementations must swallow/log internal
+failures rather than propagating them to the calling boundary code" (`:13-14`).
+
+### The redaction seam — `IAuditPayloadFilter` (pure, never throws)
+
+`src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/IAuditPayloadFilter.cs:22` — `AuditEvent Apply(
+AuditEvent rawEvent)` (`:30`). Filters an event between construction and persistence:
+truncates oversized payloads, redacts headers/body/SQL params, sets `PayloadTruncated`.
+**Pure function** returning a filtered COPY via `with` expressions, and **MUST NOT throw** —
+on internal failure it over-redacts and increments the `AuditRedactionFailure` health metric
+(`:11-20`, `:26-28`). This is exactly the canonical `IAuditRedactor` under a different name.
+Two implementations: `DefaultAuditPayloadFilter.cs:56` (full truncation + header/body/SQL
+redaction with live options) and `SafeDefaultAuditPayloadFilter.cs:19` (always-safe fallback —
+header-only redaction, over-redacts on parse failure, `:42-59`).
+
+### Transport / storage / pipeline — stays per-project
+
+The `ZB.MOM.WW.ScadaBridge.AuditLog` project is split into `Site/`, `Central/`, `Payload/`, and
+`Configuration/`. This is the bespoke half and is **not** a candidate for extraction; cited here
+only to show the scale around the common core:
+
+- **Site hot-path:** `Site/SqliteAuditWriter.cs:32` (`IAuditWriter` over an owned `SqliteConnection`
+ fed by a bounded `Channel` drained on a background task, so script-thread callers never block
+ on disk I/O; first-write-wins on duplicate `EventId`). `Site/FallbackAuditWriter.cs:28` composes
+ the SQLite writer with a drop-oldest `RingBufferFallback` so a primary failure never bubbles out.
+ `Site/Telemetry/` forwards rows to central over Akka `ClusterClient`.
+- **Central ingest/store:** `Central/CentralAuditWriter.cs:40` (`ICentralAuditWriter`, direct MS SQL
+ write for central-originated events, per-call EF scope, idempotent `InsertIfNotExistsAsync`,
+ swallows every exception per "alog.md §13"). `Central/AuditLogIngestActor.cs:46` batches site
+ telemetry; `Central/SiteAuditReconciliationActor.cs:68` periodically pulls to catch dropped
+ forwards; `Central/AuditLogPurgeActor.cs:58` enforces retention; `Central/AuditLogPartitionMaintenanceService.cs:55`
+ manages the partitioned table.
+- **CLI:** `CLI/Commands/AuditCommands.cs:12` builds `export` (`:137`, formats `csv`/`jsonl`/`parquet`)
+ and `verify-chain` (`:226`). Hash-chain verify is currently a **v1 no-op stub** —
+ `CLI/Commands/AuditVerifyChainHelpers.cs:6-10` ("v1 is a no-op").
+- **UI:** Blazor pages under `CentralUI/Components/Pages/Audit/` (e.g. `AuditLogPage.razor:1`,
+ gated by `[Authorize(Policy = AuthorizationPolicies.OperationalAudit)]`) plus drill-down
+ components in `CentralUI/Components/Audit/`.
+- **Wiring:** `AuditLog/ServiceCollectionExtensions.cs:59` `AddAuditLog(...)`, `:316`
+ `AddAuditLogCentralMaintenance(...)`.
+
+## 2. Mapping to the canonical record
+
+Target (`ZB.MOM.WW.Audit`, being built): `record AuditEvent { Guid EventId; DateTimeOffset
+OccurredAtUtc; string Actor; string Action; AuditOutcome Outcome; string? Category; string?
+Target; string? SourceNode; Guid? CorrelationId; string? DetailsJson; }`. ScadaBridge's record is
+a strict superset — the canonical fields map directly; the rich extras collapse into `DetailsJson`.
+
+| Canonical field | ScadaBridge source | Notes |
+|---|---|---|
+| `EventId` (Guid) | `AuditEvent.EventId` | Direct; same idempotency-key role. |
+| `OccurredAtUtc` (DateTimeOffset) | `AuditEvent.OccurredAtUtc` (`DateTime`, UTC-forced) | Type bridge `DateTime`(Utc)↔`DateTimeOffset`; semantics identical. |
+| `Actor` (string) | `AuditEvent.Actor` (nullable) | Direct; ScadaBridge allows null (system-originated rows). |
+| `Action` (string) | `AuditEvent.Kind` (+`Channel`) | Derive a stable action string, e.g. `{Channel}.{Kind}` (`ApiOutbound.ApiCall`). |
+| `Outcome` (Success/Failure/Denied) | `AuditEvent.Status` | `Delivered`→Success; `Failed`/`Parked`/`Discarded`→Failure; `InboundAuthFailure`(Kind)→Denied; in-flight `Submitted`/`Forwarded`/`Attempted` collapse to the last-known terminal state when projecting. |
+| `Category` (string?) | `AuditEvent.Channel` | The coarse bucket; pairs with `Action` above. |
+| `Target` (string?) | `AuditEvent.Target` | Direct. |
+| `SourceNode` (string?) | `AuditEvent.SourceNode` | Direct (`node-a`/`central-b`/…). |
+| `CorrelationId` (Guid?) | `AuditEvent.CorrelationId` | Direct (per-op lifecycle id). |
+| `DetailsJson` (string?) | `ExecutionId`, `ParentExecutionId`, `SourceSiteId`, `SourceInstanceId`, `SourceScript`, `HttpStatus`, `DurationMs`, `ErrorMessage`, `ErrorDetail`, `RequestSummary`, `ResponseSummary`, `PayloadTruncated`, `Extra`, `IngestedAtUtc`, `ForwardState` | The ~15 rich/plumbing fields serialize into the canonical `DetailsJson` extension. |
+
+The canonical record is a lossy *projection* of ScadaBridge's — fine for cross-project
+reporting, but ScadaBridge keeps its full record as the storage shape (the partitioned SQL
+schema, forwarding state, and reconciliation all depend on the extra columns).
+
+## 3. Adoption plan → `ZB.MOM.WW.Audit`
+
+**Posture: align, don't replace.** ScadaBridge is the reference implementation the shared
+library is being extracted *from*; it already has both seams. Adoption is mostly renaming and
+contract-confirmation, with a deliberately small touched surface and a large blast radius if
+done carelessly. **Priority: LOW. Blast radius: HIGH.**
+
+**Align (small, naming-level):**
+- **Rename the redaction seam to match the contract.** `IAuditPayloadFilter` → adopt
+ `ZB.MOM.WW.Audit.IAuditRedactor` (`AuditEvent Apply(AuditEvent)` — identical signature and
+ pure/never-throws contract). Either alias `IAuditPayloadFilter : IAuditRedactor` during
+ transition or rename outright; `DefaultAuditPayloadFilter` / `SafeDefaultAuditPayloadFilter`
+ implement it unchanged. See [`../../shared-contract/`](../../shared-contract/).
+- **Confirm the writer contract matches.** `IAuditWriter.WriteAsync(AuditEvent, CancellationToken
+ = default)` is already byte-for-byte the canonical signature, and the "never abort the
+ user-facing action" wording matches. The only delta is the **record type**: the library's
+ `IAuditWriter` is typed on the *canonical* 10-field `AuditEvent`, while ScadaBridge's is typed on
+ its ~25-field record. Resolve by either (a) keeping ScadaBridge's writer on its own rich record
+ and adopting only the library's *interface name + outcome enum*, or (b) having the shared seam be
+ generic over the event type. **Recommended: (a)** — adopt the canonical `AuditOutcome` enum and
+ the interface naming, but keep the bespoke `AuditEvent` as ScadaBridge's storage record, since the
+ whole transport/partition/forwarding layer is built on its extra columns. (Best-practice fit: this
+ is the minimal-coupling option — share the contract, not the schema.)
+
+**Keep bespoke (the large, untouched majority):**
+- The entire `Site/` (SQLite hot-path + ring-buffer fallback + telemetry forwarder) and `Central/`
+ (ingest / reconcile / purge / partition maintenance) pipeline.
+- The `AuditEvent` rich record itself, the four domain enums (`AuditChannel`/`AuditKind`/
+ `AuditStatus`/`AuditForwardState`), CLI `export`/`verify-chain`, and the Blazor audit UI.
+- The redaction *policy* (`DefaultAuditPayloadFilter` options, per-target overrides) — only the
+ interface name is shared, not the implementation.
+
+**Net:** ScadaBridge converges by renaming one interface and adopting the canonical `AuditOutcome`
+enum + the `Kind`/`Channel`→`Action`/`Category` and `…`→`DetailsJson` projection for any
+cross-project reporting. No transport, storage, CLI, or UI is replaced. Sequencing and the
+cross-project gap list live in [`../../GAPS.md`](../../GAPS.md); the canonical target is
+[`../../spec/SPEC.md`](../../spec/SPEC.md).
diff --git a/components/audit/shared-contract/ZB.MOM.WW.Audit.md b/components/audit/shared-contract/ZB.MOM.WW.Audit.md
new file mode 100644
index 0000000..a120bff
--- /dev/null
+++ b/components/audit/shared-contract/ZB.MOM.WW.Audit.md
@@ -0,0 +1,153 @@
+# Proposed shared library: `ZB.MOM.WW.Audit`
+
+A contract on paper — the public surface to extract so the three projects stop
+re-implementing audit-event capture with incompatible shapes. Realizes
+[`../spec/SPEC.md`](../spec/SPEC.md).
+**Not yet created.** Reference implementations already exist: ScadaBridge's
+`IAuditWriter`/`IAuditPayloadFilter` (already at target shape), mxaccessgw
+structured-log audit trail, OtOpcUa admin-UI audit log.
+
+## Package (.NET 10)
+
+```
+ZB.MOM.WW.Audit # the single package: event record, seams, helpers, DI wiring
+```
+
+Single package, single DLL. Only non-BCL dependency:
+`Microsoft.Extensions.DependencyInjection.Abstractions` (for `AddZbAudit`).
+Published to the Gitea NuGet feed; SemVer.
+
+| Package (→ DLL) | Transitive deps | OtOpcUa | mxaccessgw | ScadaBridge |
+|---|---|---|---|---|
+| `ZB.MOM.WW.Audit` | `Microsoft.Extensions.DependencyInjection.Abstractions` | ✅ | ✅ | ✅ |
+
+All three auth-bearing processes are .NET 10 — the x86/net48 mxaccessgw worker does
+no audit emission, so net48 multi-targeting is **not** required.
+
+## `AuditEvent` record and `AuditOutcome` enum
+
+```csharp
+public sealed record AuditEvent {
+ public required Guid EventId { get; init; }
+ public required DateTimeOffset OccurredAtUtc { get; init; } // normalized to UTC on assignment
+ public required string Actor { get; init; }
+ public required string Action { get; init; }
+ public required AuditOutcome Outcome { get; init; }
+ public string? Category { get; init; }
+ public string? Target { get; init; }
+ public string? SourceNode { get; init; }
+ public Guid? CorrelationId { get; init; }
+ public string? DetailsJson { get; init; }
+}
+
+public enum AuditOutcome { Success, Failure, Denied }
+```
+
+`OccurredAtUtc` is the only field with a normalization contract: any value assigned
+is coerced to UTC (via `ToUniversalTime()`). All other fields are caller-supplied and
+carried through without transformation by the library internals.
+
+## Seams
+
+### `IAuditWriter`
+
+```csharp
+public interface IAuditWriter
+{
+ Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
+}
+```
+
+**Hard contract:**
+- Best-effort delivery. The implementation **MUST swallow all internal failures** and
+ **MUST NOT throw** to the caller. A write that fails silently is preferable to
+ a write that crashes the calling thread or kills a request pipeline.
+- `CancellationToken` is respected for cooperative cancellation but a cancellation
+ does not constitute a contract violation; the implementation may choose to complete
+ a partially-written event anyway.
+
+### `IAuditRedactor`
+
+```csharp
+public interface IAuditRedactor
+{
+ AuditEvent Apply(AuditEvent rawEvent);
+}
+```
+
+**Hard contract:**
+- Pure function (no I/O, no side effects).
+- **MUST NOT throw.** On any internal failure the implementation must over-redact
+ (e.g. replace the affected field with a sentinel such as `"[redacted]"`) rather
+ than propagate the exception. Lossier output is always preferable to a thrown
+ exception reaching the caller.
+
+## Shipped helpers (concrete)
+
+### Redactors
+
+| Type | Behaviour |
+|---|---|
+| `NullAuditRedactor` | Identity — returns the event unchanged. Registered as the default by `AddZbAudit`. |
+| `TruncatingAuditRedactor` | Caps `DetailsJson` and `Target` to a configurable maximum length and appends a marker (e.g. `"…"`) when truncated. Never throws. Configured via `TruncatingAuditRedactorOptions`. |
+| `TruncatingAuditRedactorOptions` | Options record for `TruncatingAuditRedactor`: `MaxDetailsJsonLength`, `MaxTargetLength`, `TruncationMarker`. |
+
+### Writers
+
+| Type | Behaviour |
+|---|---|
+| `NoOpAuditWriter` | Discards every event. Registered as the default by `AddZbAudit`; consumer replaces with a real writer. |
+| `CompositeAuditWriter` | Fan-out: forwards each event to an ordered list of inner `IAuditWriter` instances. A failing inner writer is swallowed (per the `IAuditWriter` contract) — it does **not** abort the remaining writers in the list. |
+| `RedactingAuditWriter` | Decorator: calls `IAuditRedactor.Apply` on the event, then delegates the redacted event to an inner `IAuditWriter`. Separates the redaction concern from any concrete writer. |
+
+## DI wiring
+
+```csharp
+public static IServiceCollection AddZbAudit(this IServiceCollection services);
+```
+
+Registers defaults via `TryAdd` so any prior consumer registration wins:
+
+- `IAuditRedactor` → `NullAuditRedactor` (singleton)
+- `IAuditWriter` → `NoOpAuditWriter` (singleton)
+
+A consumer that registers its own `IAuditWriter` (e.g. a Serilog-backed writer or a
+`CompositeAuditWriter`) before or after calling `AddZbAudit` will see its registration
+respected. `AddZbAudit` does **not** clear or override existing registrations.
+
+## Relationship to Telemetry (`ILogRedactor`)
+
+`IAuditRedactor` mirrors Telemetry.Serilog's `ILogRedactor` in shape and naming — same
+single-method contract, same "pure, must not throw, over-redact on failure" semantics —
+so that a future `ZB.MOM.WW.Hosting` aggregator package can wire both behind a single
+configuration surface without an impedance mismatch.
+
+`ZB.MOM.WW.Audit` has **no dependency** on `ZB.MOM.WW.Telemetry` or any Serilog package.
+The alignment is intentional design convergence; the independence is a hard boundary.
+
+## What stays in each consumer
+
+OtOpcUa: admin-UI audit sink (Blazor event handler → `IAuditWriter`), `Category`
+constants specific to OPC UA operations.
+
+mxaccessgw: gRPC interceptor that captures actor/action from call metadata; constraint-aware
+`Category` tagging; `DetailsJson` serialization of gateway-specific payloads.
+
+ScadaBridge: site-scoped `SourceNode` population; `ManagementActor` enforcement callbacks;
+`IAuditPayloadFilter` → `IAuditRedactor` migration (shape is already equivalent — adoption
+is a near-zero-effort rename).
+
+## Open contract questions
+
+1. **Batching**: a `WriteBatchAsync(IEnumerable, CancellationToken)` overload on
+ `IAuditWriter` may be warranted once a database-backed writer is in use. Defer until
+ the first consumer demonstrates the need; batching can be added without breaking the
+ existing single-event surface.
+2. **Structured `DetailsJson`**: confirm whether callers should supply raw JSON strings or
+ whether a typed `TDetails` generic overload (serialized internally) is cleaner. The
+ current `string?` keeps the library dependency-free but shifts serialization to the caller.
+3. **`CompositeAuditWriter` error policy**: decide whether per-writer failure should be
+ observable (e.g. an optional `ILogger`) or always silently dropped.
+ Logging the failure is diagnostic-friendly but adds a logging dependency.
+
+See [`../GAPS.md`](../GAPS.md) for the adoption order and effort/risk.
diff --git a/components/audit/spec/EVENT-MODEL.md b/components/audit/spec/EVENT-MODEL.md
new file mode 100644
index 0000000..9cc6181
--- /dev/null
+++ b/components/audit/spec/EVENT-MODEL.md
@@ -0,0 +1,94 @@
+# Canonical event model (standardized)
+
+Status: **Standardized**. The org-wide audit record + outcome enum every sister project maps onto.
+This is the reference companion to [`SPEC.md`](SPEC.md) (mirroring auth's `CANONICAL-ROLES.md` /
+theme's `DESIGN-TOKENS.md`): the field-by-field canonical record, the `AuditOutcome` definition with
+which app states map onto each value, and the full per-project mapping table. The shared library
+defines exactly this record; each project **projects its native record onto it** at the seam.
+
+## The canonical record
+
+```csharp
+namespace ZB.MOM.WW.Audit;
+
+public sealed record AuditEvent
+{
+ // REQUIRED core — who / what / when / outcome
+ public required Guid EventId { get; init; } // idempotency key
+ public required DateTimeOffset OccurredAtUtc { get; init; } // normalized to UTC
+ public required string Actor { get; init; } // who — = ZB.MOM.WW.Auth principal at adoption
+ public required string Action { get; init; } // what — verb / event-type string
+ public required AuditOutcome Outcome { get; init; } // Success | Failure | Denied
+
+ // OPTIONAL common
+ public string? Category { get; init; } // subsystem / grouping bucket
+ public string? Target { get; init; } // on-what (resource / method / connection)
+ public string? SourceNode { get; init; } // emitting logical node / host
+ public Guid? CorrelationId { get; init; } // join to originating request / workflow
+
+ // EXTENSION — everything project-specific, as JSON
+ public string? DetailsJson { get; init; }
+}
+
+public enum AuditOutcome { Success, Failure, Denied }
+```
+
+### Field-by-field
+
+| Field | Req? | Type | Meaning | Notes |
+|---|:-:|---|---|---|
+| `EventId` | yes | `Guid` | Idempotency key | Backs at-least-once transports: OtOpcUa's filtered-unique `EventId` index, ScadaBridge's first-write-wins. MxGateway has none today → **generate at write time**. |
+| `OccurredAtUtc` | yes | `DateTimeOffset` | When it happened, UTC | MxGateway already uses `DateTimeOffset`. OtOpcUa / ScadaBridge store UTC-forced `DateTime` and widen at the mapping boundary. |
+| `Actor` | yes | `string` | Who acted | SHOULD be the `ZB.MOM.WW.Auth` principal ([`SPEC.md`](SPEC.md) §4). Kept a `string` (no Auth dependency). Keyless events use a `"system"` / `"cli"` fallback rather than empty. |
+| `Action` | yes | `string` | What was done (verb / event-type) | Carries each app's domain verb: OtOpcUa `EventType`, MxGateway `EventType`, ScadaBridge `{Channel}.{Kind}`. |
+| `Outcome` | yes | `AuditOutcome` | Success / Failure / Denied | **New normalized field — no app stores it today; each derives it** (see below). |
+| `Category` | no | `string?` | Coarse subsystem / grouping | OtOpcUa `Category` (`"Config"`); MxGateway constant `"ApiKey"`; ScadaBridge `Channel`. |
+| `Target` | no | `string?` | The object acted on | ScadaBridge `Target` (direct). OtOpcUa / MxGateway have no dedicated field → null or fold into `DetailsJson`. |
+| `SourceNode` | no | `string?` | Emitting logical node / host | OtOpcUa `SourceNode` (a logical node name, **not** an OPC UA NodeId); ScadaBridge `SourceNode`; MxGateway `RemoteAddress`. |
+| `CorrelationId` | no | `Guid?` | Join to originating request / workflow | OtOpcUa / ScadaBridge direct; MxGateway has none today (left null). |
+| `DetailsJson` | no | `string?` | Extension bag — all project-specific data | Must be valid JSON where stored (OtOpcUa enforces this with a CHECK constraint). Absorbs each app's surplus columns. |
+
+## `AuditOutcome` — definition and app-state mapping
+
+Three values, deliberately minimal — enough to normalize denials and failures without importing any
+app's full taxonomy. `Outcome` is **derived** at each emit site (no app persists it today; OtOpcUa
+encodes it implicitly in `EventType`, MxGateway in the event-type literal, ScadaBridge in `Status`):
+
+| `AuditOutcome` | Meaning | OtOpcUa (`EventType`) | MxGateway (event type) | ScadaBridge (`AuditStatus` / `AuditKind`) |
+|---|---|---|---|---|
+| **`Success`** | The action completed | config-write verbs — `DraftCreated`, `DraftEdited`, `Published`, `RolledBack`, `NodeApplied`, `CredentialAdded`, `ClusterCreated`, `NodeAdded`, `ExternalIdReleased`, … | key-lifecycle — `init-db`, `create-key`, `list-keys`, `revoke-key`, `rotate-key` + all `dashboard-*` | `Status = Delivered` |
+| **`Failure`** | The action was attempted and failed | *(none today — a failed actor flush is dropped, not recorded as an event)* | *(none emitted today)* | `Status ∈ { Failed, Parked, Discarded }` |
+| **`Denied`** | The action was rejected by authorization / policy | `OpcUaAccessDenied`, `CrossClusterNamespaceAttempt` | `constraint-denied` | `Kind = InboundAuthFailure` |
+
+Notes:
+
+- **OtOpcUa has no `Failure` source.** Its vocabulary only distinguishes success-verbs from
+ access-denials; an internal write failure is dropped (best-effort), not emitted as an event. So
+ OtOpcUa produces only `Success` / `Denied` until/unless it adds failure events.
+- **MxGateway emits only `Success` / `Denied`** today (no failure events; authentication
+ success/failure is surfaced as gRPC status, not persisted — see its current-state doc).
+- **ScadaBridge in-flight states** (`Submitted` / `Forwarded` / `Attempted`) are not terminal; when
+ projecting to a single `Outcome` they collapse to the last-known terminal state. `Skipped` is not a
+ user-facing outcome and is excluded from the canonical projection.
+
+## Per-project mapping table (canonical ← native record)
+
+Consolidated from the three current-state docs. "Direct" = field exists with the same role; the
+right-hand notes flag the type bridges and synthesized fields.
+
+| Canonical field | OtOpcUa `AuditEvent` (8 fields) | MxGateway `ApiKeyAuditRecord` (6 fields) | ScadaBridge `AuditEvent` (~25 fields) |
+|---|---|---|---|
+| `EventId` | `EventId` — direct (idempotency key) | **generate** new `Guid` (only `AuditId` rowid exists) | `EventId` — direct |
+| `OccurredAtUtc` | `OccurredAtUtc` (`DateTime` UTC) → widen | `CreatedUtc` (store-assigned `DateTimeOffset`) — direct | `OccurredAtUtc` (`DateTime` UTC-forced) → widen |
+| `Actor` | `Actor` — direct | `KeyId` (nullable → `"system"`/`"cli"` fallback) | `Actor` (nullable on system rows) |
+| `Action` | `Action` (persisted as `"{Category}:{Action}"`) | `EventType` — direct | `{Channel}.{Kind}` (e.g. `ApiOutbound.ApiCall`) |
+| `Outcome` | **derive** from `EventType` | **derive**: `constraint-denied`→`Denied`, else `Success` | **derive** from `Status` (+`InboundAuthFailure`→`Denied`) |
+| `Category` | `Category` (`"Config"`) | constant `"ApiKey"` | `Channel` |
+| `Target` | — none — (null or via `DetailsJson`) | — none — (`commandKind`/`target` embedded in `Details` text) | `Target` — direct |
+| `SourceNode` | `SourceNode` (logical node, `NodeId.Value`) | `RemoteAddress` (dashboard path only) | `SourceNode` — direct |
+| `CorrelationId` | `CorrelationId` (`CorrelationId.Value`) — direct | — none — | `CorrelationId` — direct |
+| `DetailsJson` | `DetailsJson` — direct (also `ClusterId`/`GenerationId` on the SP path) | `Details` (plain string → store as-is or wrap) | the ~15 rich/plumbing fields (`ExecutionId`, `SourceSiteId`, `HttpStatus`, `DurationMs`, `ErrorMessage`, `RequestSummary`, `ResponseSummary`, `PayloadTruncated`, `Extra`, `ForwardState`, …) serialize here |
+
+The canonical record is a **lossy projection**: it is sufficient for cross-project reporting, but each
+project keeps its native record as the storage shape — ScadaBridge especially, whose partitioned SQL
+schema, forwarding state, and reconciliation depend on the extra columns ([`SPEC.md`](SPEC.md) §5).
diff --git a/components/audit/spec/SPEC.md b/components/audit/spec/SPEC.md
new file mode 100644
index 0000000..1d81310
--- /dev/null
+++ b/components/audit/spec/SPEC.md
@@ -0,0 +1,146 @@
+# Audit — normalized target spec
+
+Status: **Draft**. The single design the sister projects converge on. Derived from the three
+code-verified current-state docs (`../current-state/`) and the locked design
+(`../../../docs/plans/2026-06-01-audit-component-design.md`). Goal is *path to shared code*
+(`../shared-contract/ZB.MOM.WW.Audit.md`), so each normalized section maps to a shared library seam.
+
+## 0. Normalized vs left-per-project
+
+**Normalized here** (the shared `ZB.MOM.WW.Audit` library):
+
+- **The canonical `AuditEvent` record** — required core (`EventId`, `OccurredAtUtc`, `Actor`,
+ `Action`, `Outcome`) + optional common (`Category`, `Target`, `SourceNode`, `CorrelationId`) +
+ the `DetailsJson` extension bag. The full field-by-field reference is [`EVENT-MODEL.md`](EVENT-MODEL.md).
+- **`AuditOutcome`** — the 3-value `Success | Failure | Denied` enum (§3). This is a *new*
+ normalized field every app derives; see [`EVENT-MODEL.md`](EVENT-MODEL.md) for the per-app derivation.
+- **The two seams** — `IAuditWriter` (best-effort, never throws to caller, §1) and `IAuditRedactor`
+ (pure, never throws, over-redacts on failure, §2).
+
+**Explicitly NOT normalized** (domain-specific / divergent — keep per project):
+
+- **Transport & storage** — OtOpcUa's Akka cluster-broadcast → singleton `AuditWriterActor` (batch
+ 500 / 5 s, two-layer dedup) over `ConfigAuditLog`; MxGateway's SQLite `IApiKeyAuditStore` append +
+ list-recent; ScadaBridge's site-SQLite hot-path → central MS SQL ingest / reconcile / purge /
+ partition-maintenance / hash-chain pipeline. The shared core carries no Akka / EF / SQLite /
+ Serilog dependency; its only non-BCL dependency is `Microsoft.Extensions.DependencyInjection.Abstractions`
+ (for `AddZbAudit`).
+- **Domain vocabulary** — ScadaBridge's `Channel` / `Kind` / `Status` / `ForwardState` enums and
+ OtOpcUa's `EventType` strings (`DraftCreated`, `Published`, `OpcUaAccessDenied`, …). These map
+ *into* `Action` / `Category` / `Outcome` / `DetailsJson`; they do not leak into the shared type.
+- **Query / CLI / UI / export** surfaces (OtOpcUa `ClusterAudit.razor`; ScadaBridge `export` /
+ `verify-chain` CLI + Blazor audit pages; MxGateway's unused `ListRecentAsync`).
+- **Each app's redaction *policy*** — *which* fields/commands/payloads are sensitive. Only the
+ `IAuditRedactor` *seam* is shared; the `Default` / `Safe` filter behaviour stays per-project.
+
+> **Scope of the producer path.** OtOpcUa has **two producers** writing the same `ConfigAuditLog`
+> table — the structured Akka `AuditEvent` path *and* older SQL stored procedures that `INSERT`
+> directly (`SUSER_SNAME()`, bare `EventType`, NULL `EventId`). Normalization targets the
+> **structured producer path** (the one that builds an `AuditEvent`), not every SQL insert; the SP
+> path stays per-project and is a reconcile item, not an extraction item (`../GAPS.md`).
+
+## 1. The writer contract — `IAuditWriter` (best-effort)
+
+```csharp
+public interface IAuditWriter
+{
+ Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
+}
+```
+
+Audit is a side-channel, never on the critical path. The hard rule:
+
+- **`WriteAsync` MUST NOT throw to the caller.** An implementation swallows/logs its own internal
+ failures; a failed write **must never abort the user-facing action** it is recording. (ScadaBridge's
+ seam already states this almost word-for-word: "Failures must NEVER abort the user-facing action.")
+- Idempotency is carried by `EventId`, so retries and at-least-once transports are safe (OtOpcUa's
+ filtered-unique `EventId` index and ScadaBridge's first-write-wins are both honoured by this key).
+- Delivery is at-most-once *as a contract* — a writer MAY drop on failure (OtOpcUa drops a failed
+ batch; ScadaBridge's ring-buffer fallback drops oldest). Durability is a per-project transport
+ decision, not part of this seam.
+
+Shipped helpers (the only concrete writers): `NoOpAuditWriter` (discards — tests / disabled audit),
+`CompositeAuditWriter` (fans out to N writers; **one writer throwing does not stop the others**), and
+`RedactingAuditWriter` (decorator: applies the redactor, then delegates to an inner writer).
+
+## 2. The redactor contract — `IAuditRedactor` (never throws)
+
+```csharp
+public interface IAuditRedactor
+{
+ AuditEvent Apply(AuditEvent rawEvent);
+}
+```
+
+A pure projection from a raw event to a safe one, applied between event construction and the writer
+chain. The hard rule:
+
+- **`Apply` MUST NOT throw.** On any internal failure it **over-redacts** (returns a strictly safer
+ event) rather than propagating — a redactor that throws would either crash the audit path or leak
+ the unredacted event. (ScadaBridge's `SafeDefaultAuditPayloadFilter` is the reference: header-only
+ redaction, over-redacts on parse failure.)
+- It is a **pure function** returning a filtered *copy* (via `with`); it does not mutate the input or
+ perform I/O.
+
+The seam is **aligned-but-independent** with Telemetry's `ILogRedactor` — same shape and naming
+discipline so a future `ZB.MOM.WW.Hosting` aggregator wires both with one mental model — but there is
+**no cross-package dependency**. Shipped helpers: `NullAuditRedactor` (identity — the default when no
+policy is configured) and `TruncatingAuditRedactor` (caps `DetailsJson` / `Target` to a configured
+max + sets a truncation marker; never throws). The *secret-field policy* (which fields/commands are
+sensitive) stays per-project via composition.
+
+## 3. `AuditOutcome` — the new normalized field
+
+`Outcome` is in the **required core**, but **no app stores it today** — each encodes outcome
+implicitly and must **derive** it at adoption (this is the one genuinely new field):
+
+- **OtOpcUa** — derived from the `EventType` vocabulary (`OpcUaAccessDenied` /
+ `CrossClusterNamespaceAttempt` → `Denied`; config-write verbs → `Success`).
+- **MxGateway** — `constraint-denied` → `Denied`; key-lifecycle events → `Success`.
+- **ScadaBridge** — `AuditStatus` → `Outcome` (`Delivered` → `Success`; `Failed` / `Parked` /
+ `Discarded` → `Failure`; `InboundAuthFailure` kind → `Denied`).
+
+The three values normalize denials and failures across the family without importing any app's full
+taxonomy. The enum definition and the complete state-by-state mapping live in [`EVENT-MODEL.md`](EVENT-MODEL.md).
+
+## 4. The hinge — audit closes the loop on Auth
+
+Every audit row's `Actor` is the *who*, which is exactly the identity the **Auth** component already
+normalizes (LDAP/GLAuth principal, API-key name). Auth is the read side ("who is this and what may
+they do"); audit is the write side ("who did what"). The spec ties them by stating:
+
+- **`Actor` SHOULD be the `ZB.MOM.WW.Auth` principal** at adoption time.
+- But `Actor` is **kept as a plain `string`** in the contract, so the library carries **no dependency
+ on `ZB.MOM.WW.Auth`**. (MxGateway's keyless events — `init-db` / `list-keys` — supply a `"system"` /
+ `"cli"` fallback rather than leaving the required field empty.)
+
+This mirrors Auth's own decision to keep audit *read* inside `OBSERVE` and audit *export* inside
+`ADMINISTER` rather than minting a separate auditor role: the two components share a vocabulary, not a
+dependency.
+
+## 5. ScadaBridge is already at the target
+
+ScadaBridge already ships **both** seams: an `IAuditWriter` whose best-effort contract matches
+word-for-word, and an `IAuditPayloadFilter` that *is* the canonical `IAuditRedactor` under a different
+name (identical `AuditEvent Apply(AuditEvent)` signature, pure / never-throws / over-redacts). The
+library essentially **lifts ScadaBridge's seams**.
+
+The one real (non-naming) decision is the **writer's record type**: the canonical `IAuditWriter` is
+typed on the 10-field `AuditEvent`; ScadaBridge's writer is typed on its ~25-field record.
+
+> **Resolution (recommended):** share the **interface *name* + the `AuditOutcome` enum**, not the
+> record schema. ScadaBridge keeps its rich ~25-field record as its **storage shape** (its whole
+> transport / partition / forwarding / reconciliation layer is built on the extra columns), and maps
+> to the canonical 10-field record **only at cross-app reporting boundaries**. This is the
+> minimal-coupling option — share the contract, not the schema — and avoids making the shared seam
+> generic over the event type. ScadaBridge therefore converges by **renaming one interface** and
+> adopting `AuditOutcome`, with no transport / storage / CLI / UI change.
+
+## 6. Acceptance (what "converged" means)
+
+A project is converged when: (a) its structured audit-producer path constructs the canonical
+`AuditEvent` (with `Outcome` derived per §3) and persists via an implementation of `IAuditWriter`;
+(b) any redaction runs through an `IAuditRedactor`; (c) `Actor` carries the `ZB.MOM.WW.Auth` principal
+where one exists (string fallback otherwise); with its transport, storage, domain vocabulary, query
+surfaces, and redaction *policy* unchanged. Per-project deltas and the adoption backlog are in
+[`../GAPS.md`](../GAPS.md); the proposed library API is [`../shared-contract/ZB.MOM.WW.Audit.md`](../shared-contract/ZB.MOM.WW.Audit.md).
diff --git a/upcoming.md b/upcoming.md
index cde7775..3d8ee86 100644
--- a/upcoming.md
+++ b/upcoming.md
@@ -49,7 +49,7 @@ Three different, incompatible approaches mean you can't scrape or dashboard the
(+ `Commons` `OtOpcUaTelemetry`); MxGateway `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs`
(~470 LOC, no exporter); ScadaBridge `OpenTelemetry.Api` dep only (no instrumentation).
-### 3. Audit — shared event *model* + writer seam
+### 3. Audit — shared event *model* + writer seam ✅ DONE
All three audit; the who-did-what record (actor / action / target / time / correlationId /
detailsJson) is genuinely common, and it **closes the loop on the Auth component** (audit's
"who" = identity). Transport differs (Akka cluster vs SQLite vs none), so extract only the
@@ -58,6 +58,9 @@ ScadaBridge's pipeline is ~3k LOC.
- Evidence: OtOpcUa `Commons/Messages/Audit/AuditEvent.cs` + `ControlPlane/Audit/AuditWriterActor.cs`
+ `Configuration/Entities/ConfigAuditLog.cs`; ScadaBridge `src/ZB.MOM.WW.ScadaBridge.AuditLog/`
(site + central) + `Commons/Entities/Audit/`; MxGateway `Security/Authentication/SqliteApiKeyAuditStore.cs`.
+- **Delivered:** shared library built at [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) (1 nupkg @ 0.1.0);
+ design at [`components/audit/`](components/audit/); adoption backlog in
+ [`components/audit/GAPS.md`](components/audit/GAPS.md).
### Strategic — the gRPC `.proto` break surface
[`CLAUDE.md`](CLAUDE.md) names these as *the* cross-repo break surface ("a green build in one
@@ -70,7 +73,10 @@ cross-repo interop checks, distinct from the others.
### Tier 2 (good, with caveats)
- **Logging — `ZB.MOM.WW.Logging`:** strong overlap (Serilog bootstrap + enrichers SiteId/NodeRole/Host
+ correlation scope), but MxGateway uses MS.Extensions.Logging — step 1 is converging on Serilog.
- Natural to bundle with Telemetry as "observability".
+ Natural to bundle with Telemetry as "observability". **Note:** the logging-family work for this
+ family is being delivered as `ZB.MOM.WW.Telemetry.Serilog` by the health/observability normalization
+ pass ([`components/health/`](components/health/)), not as a standalone `ZB.MOM.WW.Logging` lib —
+ a separate Logging candidate is not expected.
- **Config validation conventions:** all three use IOptions + `IValidateOptions` + `ValidateOnStart`;
a shared validation base + startup-validation helper is reusable and pairs with the Auth options pattern.