diff --git a/docs/plans/2026-05-20-auditlog-m8-cli.md b/docs/plans/2026-05-20-auditlog-m8-cli.md
new file mode 100644
index 0000000..3e68f00
--- /dev/null
+++ b/docs/plans/2026-05-20-auditlog-m8-cli.md
@@ -0,0 +1,21 @@
+# Audit Log #23 — M8 CLI Implementation Plan
+
+> **For Claude:** subagent-driven-development with bundled cadence. FINAL milestone.
+
+**Goal:** Operator CLI surface — `scadalink audit query | export | verify-chain` — plus the ManagementService HTTP endpoints they call, output formatters, and renaming the pre-existing `audit-log` config-change command to `audit-config` with a deprecation alias.
+
+**M7 realities baked in:**
+- `OperationalAudit` + `AuditExport` are role-claim policies (M7 Bundle G). The Management endpoints reuse them.
+- `IAuditLogRepository.QueryAsync` (keyset paging) + `GetKpiSnapshotAsync` exist.
+- `AuditLogQueryFilter` is single-value per dimension — the CLI's `--channel` etc. flags collapse to single values like the UI chips do (documented limitation).
+- `verify-chain` is a v1 no-op stub (hash-chain deferred to v1.x per alog.md locked decisions). Do NOT implement hash chains.
+- ManagementService surface: confirm controllers vs minimal API by reading the project (M7 found CentralUI uses minimal API; ManagementService may differ).
+
+**CLI conventions:** System.CommandLine; JSON default + `--format table` opt-in. The CLI connects via the HTTP Management API (per CLAUDE.md). Mirror `src/ScadaLink.CLI/Commands/AuditLogCommands.cs` for the System.CommandLine pattern.
+
+**Bundles:**
+- Bundle A — CLI `audit` command group: scaffold + query + export + verify-chain (T1, T2, T3, T4).
+- Bundle B — ManagementService /api/audit/{query,export} endpoints (T5).
+- Bundle C — Output formatters + audit-config rename + README (T6, T7, T8).
+
+Final cross-bundle review + merge + roadmap closeout.
diff --git a/src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs b/src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs
new file mode 100644
index 0000000..876db62
--- /dev/null
+++ b/src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs
@@ -0,0 +1,72 @@
+using System.CommandLine;
+using System.CommandLine.Parsing;
+
+namespace ScadaLink.CLI.Commands;
+
+///
+/// Resolved Management API connection details for an audit subcommand, or an
+/// error describing why resolution failed.
+///
+public sealed class AuditConnection
+{
+ public string? Url { get; init; }
+ public string? Username { get; init; }
+ public string? Password { get; init; }
+ public string? Error { get; init; }
+ public string? ErrorCode { get; init; }
+
+ public static AuditConnection Fail(string error, string code)
+ => new() { Error = error, ErrorCode = code };
+}
+
+///
+/// Connection/format resolution shared by the audit subcommands. Mirrors the URL
+/// and credential precedence used by (command line → config
+/// file / environment), but produces a raw target
+/// because the audit endpoints are plain REST resources rather than POST /management
+/// command-envelope calls.
+///
+public static class AuditCommandHelpers
+{
+ public static AuditConnection ResolveConnection(
+ ParseResult result,
+ Option urlOption,
+ Option usernameOption,
+ Option passwordOption)
+ {
+ var config = CliConfig.Load();
+
+ var url = result.GetValue(urlOption);
+ if (string.IsNullOrWhiteSpace(url))
+ url = config.ManagementUrl;
+
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ return AuditConnection.Fail(
+ "No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadalink/config.json.",
+ "NO_URL");
+ }
+
+ if (!CommandHelpers.IsValidManagementUrl(url))
+ {
+ return AuditConnection.Fail(
+ $"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
+ "INVALID_URL");
+ }
+
+ var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username);
+ var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password);
+
+ if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
+ {
+ return AuditConnection.Fail(
+ "Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
+ "NO_CREDENTIALS");
+ }
+
+ return new AuditConnection { Url = url, Username = username, Password = password };
+ }
+
+ public static string ResolveFormat(ParseResult result, Option formatOption)
+ => CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load());
+}
diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs
new file mode 100644
index 0000000..b411b91
--- /dev/null
+++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs
@@ -0,0 +1,190 @@
+using System.CommandLine;
+using System.CommandLine.Parsing;
+
+namespace ScadaLink.CLI.Commands;
+
+///
+/// The scadalink audit command group (Audit Log #23 M8). Provides read access to
+/// the centralized append-only Audit Log via the Bundle B REST endpoints
+/// (GET /api/audit/query, GET /api/audit/export), plus a v1 no-op
+/// verify-chain placeholder for the deferred hash-chain tamper-evidence feature.
+///
+public static class AuditCommands
+{
+ public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
+ {
+ var command = new Command("audit") { Description = "Query and export the centralized audit log" };
+
+ command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption));
+ command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption));
+ command.Add(BuildVerifyChain(urlOption, formatOption, usernameOption, passwordOption));
+
+ return command;
+ }
+
+ private static Command BuildQuery(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
+ {
+ var sinceOption = new Option("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" };
+ var untilOption = new Option("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" };
+ var channelOption = new Option("--channel") { Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound)" };
+ channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
+ var kindOption = new Option("--kind") { Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve)" };
+ kindOption.AcceptOnlyFromAmong(
+ "ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
+ "NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
+ var statusOption = new Option("--status") { Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped)" };
+ statusOption.AcceptOnlyFromAmong(
+ "Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
+ var siteOption = new Option("--site") { Description = "Filter by source site ID" };
+ var targetOption = new Option("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
+ var actorOption = new Option("--actor") { Description = "Filter by actor" };
+ var correlationIdOption = new Option("--correlation-id") { Description = "Filter by correlation ID" };
+ var errorsOnlyOption = new Option("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
+ var pageSizeOption = new Option("--page-size") { Description = "Events per page (1-1000)" };
+ pageSizeOption.DefaultValueFactory = _ => 100;
+ var allOption = new Option("--all") { Description = "Fetch every page, following the keyset cursor" };
+
+ var cmd = new Command("query") { Description = "Query audit log events" };
+ cmd.Add(sinceOption);
+ cmd.Add(untilOption);
+ cmd.Add(channelOption);
+ cmd.Add(kindOption);
+ cmd.Add(statusOption);
+ cmd.Add(siteOption);
+ cmd.Add(targetOption);
+ cmd.Add(actorOption);
+ cmd.Add(correlationIdOption);
+ cmd.Add(errorsOnlyOption);
+ cmd.Add(pageSizeOption);
+ cmd.Add(allOption);
+
+ cmd.SetAction(async (ParseResult result) =>
+ {
+ var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption);
+ if (connection.Error != null)
+ {
+ OutputFormatter.WriteError(connection.Error, connection.ErrorCode!);
+ return 1;
+ }
+
+ var format = AuditCommandHelpers.ResolveFormat(result, formatOption);
+ var formatter = AuditFormatterFactory.Create(format, Console.Error);
+
+ var args = new AuditQueryArgs
+ {
+ Since = result.GetValue(sinceOption),
+ Until = result.GetValue(untilOption),
+ Channel = result.GetValue(channelOption),
+ Kind = result.GetValue(kindOption),
+ Status = result.GetValue(statusOption),
+ Site = result.GetValue(siteOption),
+ Target = result.GetValue(targetOption),
+ Actor = result.GetValue(actorOption),
+ CorrelationId = result.GetValue(correlationIdOption),
+ ErrorsOnly = result.GetValue(errorsOnlyOption),
+ PageSize = result.GetValue(pageSizeOption),
+ };
+ var fetchAll = result.GetValue(allOption);
+
+ try
+ {
+ using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
+ return await AuditQueryHelpers.RunQueryAsync(
+ client, args, fetchAll, formatter, Console.Out, DateTimeOffset.UtcNow);
+ }
+ catch (FormatException ex)
+ {
+ OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
+ return 1;
+ }
+ });
+ return cmd;
+ }
+
+ private static Command BuildExport(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
+ {
+ var sinceOption = new Option("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
+ var untilOption = new Option("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
+ var formatExportOption = new Option("--format") { Description = "Export format", Required = true };
+ formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
+ var outputOption = new Option("--output") { Description = "Destination file path", Required = true };
+ var channelOption = new Option("--channel") { Description = "Filter by channel" };
+ var kindOption = new Option("--kind") { Description = "Filter by event kind" };
+ var statusOption = new Option("--status") { Description = "Filter by status" };
+ var siteOption = new Option("--site") { Description = "Filter by source site ID" };
+ var targetOption = new Option("--target") { Description = "Filter by target" };
+ var actorOption = new Option("--actor") { Description = "Filter by actor" };
+
+ var cmd = new Command("export") { Description = "Export audit log events to a file" };
+ cmd.Add(sinceOption);
+ cmd.Add(untilOption);
+ cmd.Add(formatExportOption);
+ cmd.Add(outputOption);
+ cmd.Add(channelOption);
+ cmd.Add(kindOption);
+ cmd.Add(statusOption);
+ cmd.Add(siteOption);
+ cmd.Add(targetOption);
+ cmd.Add(actorOption);
+
+ cmd.SetAction(async (ParseResult result) =>
+ {
+ var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption);
+ if (connection.Error != null)
+ {
+ OutputFormatter.WriteError(connection.Error, connection.ErrorCode!);
+ return 1;
+ }
+
+ var args = new AuditExportArgs
+ {
+ Since = result.GetValue(sinceOption)!,
+ Until = result.GetValue(untilOption)!,
+ Format = result.GetValue(formatExportOption)!,
+ Output = result.GetValue(outputOption)!,
+ Channel = result.GetValue(channelOption),
+ Kind = result.GetValue(kindOption),
+ Status = result.GetValue(statusOption),
+ Site = result.GetValue(siteOption),
+ Target = result.GetValue(targetOption),
+ Actor = result.GetValue(actorOption),
+ };
+
+ try
+ {
+ using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
+ return await AuditExportHelpers.RunExportAsync(client, args, Console.Out, DateTimeOffset.UtcNow);
+ }
+ catch (FormatException ex)
+ {
+ OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
+ return 1;
+ }
+ });
+ return cmd;
+ }
+
+ private static Command BuildVerifyChain(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
+ {
+ var monthOption = new Option("--month") { Description = "Month to verify (YYYY-MM)", Required = true };
+
+ var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" };
+ cmd.Add(monthOption);
+ cmd.SetAction((ParseResult result) =>
+ {
+ var month = result.GetValue(monthOption)!;
+ if (!AuditVerifyChainHelpers.IsValidMonth(month))
+ {
+ OutputFormatter.WriteError(
+ $"Invalid month '{month}'. Expected YYYY-MM (e.g. 2026-05).", "INVALID_ARGUMENT");
+ return 1;
+ }
+
+ Console.WriteLine(
+ "Hash-chain tamper-evidence is not enabled in this release. "
+ + "See Component-AuditLog.md (Security & Tamper-Evidence) for the v1.x roadmap.");
+ return 0;
+ });
+ return cmd;
+ }
+}
diff --git a/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs b/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs
new file mode 100644
index 0000000..01d702b
--- /dev/null
+++ b/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs
@@ -0,0 +1,114 @@
+using System.Globalization;
+using System.Net;
+
+namespace ScadaLink.CLI.Commands;
+
+///
+/// Filter + destination arguments for an audit export invocation. Mirrors the
+/// Bundle B GET /api/audit/export parameters.
+///
+public sealed class AuditExportArgs
+{
+ public string Since { get; set; } = string.Empty;
+ public string Until { get; set; } = string.Empty;
+ public string Format { get; set; } = string.Empty;
+ public string Output { get; set; } = string.Empty;
+ public string? Channel { get; set; }
+ public string? Kind { get; set; }
+ public string? Status { get; set; }
+ public string? Site { get; set; }
+ public string? Target { get; set; }
+ public string? Actor { get; set; }
+}
+
+///
+/// Helpers for the audit export subcommand: builds the export query string and
+/// streams the HTTP response body straight to the destination file without buffering
+/// the (potentially multi-megabyte) export in memory.
+///
+public static class AuditExportHelpers
+{
+ ///
+ /// Builds the ?... query string for GET /api/audit/export: the required
+ /// time window + format, plus optional filters. Time-specs are resolved via
+ /// .
+ ///
+ public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
+ {
+ var parts = new List();
+
+ void Add(string key, string? value)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ parts.Add($"{key}={Uri.EscapeDataString(value)}");
+ }
+
+ Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture));
+ Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture));
+ Add("format", args.Format);
+ Add("channel", args.Channel);
+ Add("kind", args.Kind);
+ Add("status", args.Status);
+ Add("sourceSiteId", args.Site);
+ Add("target", args.Target);
+ Add("actor", args.Actor);
+
+ return "?" + string.Join("&", parts);
+ }
+
+ ///
+ /// Executes the export: GETs /api/audit/export and copies the response body
+ /// stream directly to . The body is never fully
+ /// buffered — streams in fixed-size chunks.
+ /// A 501 Not Implemented (parquet not yet supported server-side) prints the
+ /// server message and returns a non-zero exit code.
+ ///
+ public static async Task RunExportAsync(
+ ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now)
+ {
+ var qs = BuildQueryString(args, now);
+
+ HttpResponseMessage response;
+ try
+ {
+ response = await client.SendGetStreamAsync("api/audit/export" + qs, CancellationToken.None);
+ }
+ catch (HttpRequestException ex)
+ {
+ OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
+ return 1;
+ }
+
+ using (response)
+ {
+ if (response.StatusCode == HttpStatusCode.NotImplemented)
+ {
+ var message = await response.Content.ReadAsStringAsync();
+ OutputFormatter.WriteError(
+ string.IsNullOrWhiteSpace(message)
+ ? "Export format not implemented by the server."
+ : message,
+ "NOT_IMPLEMENTED");
+ return 1;
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var message = await response.Content.ReadAsStringAsync();
+ OutputFormatter.WriteError(
+ string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message,
+ "ERROR");
+ return 1;
+ }
+
+ await using var source = await response.Content.ReadAsStreamAsync();
+ await using var destination = new FileStream(
+ args.Output, FileMode.Create, FileAccess.Write, FileShare.None,
+ bufferSize: 81920, useAsync: true);
+ await source.CopyToAsync(destination);
+ }
+
+ output.WriteLine($"Exported audit log to {args.Output}");
+ return 0;
+ }
+}
diff --git a/src/ScadaLink.CLI/Commands/AuditFormatter.cs b/src/ScadaLink.CLI/Commands/AuditFormatter.cs
new file mode 100644
index 0000000..ebfb600
--- /dev/null
+++ b/src/ScadaLink.CLI/Commands/AuditFormatter.cs
@@ -0,0 +1,45 @@
+using System.Text.Json;
+
+namespace ScadaLink.CLI.Commands;
+
+///
+/// Renders a page of audit-log events to a writer. The audit query command picks
+/// a formatter from the --format option. The default JSONL formatter is defined
+/// here; the human-readable table formatter is supplied by Bundle C.
+///
+public interface IAuditFormatter
+{
+ /// Renders one page of events. Called once per fetched page.
+ void WritePage(IReadOnlyList events, TextWriter output);
+}
+
+///
+/// Default formatter: one JSON object per line (JSONL). Streamable — each page's events
+/// are flushed as they arrive, so --all over many pages does not accumulate.
+///
+public sealed class JsonLinesAuditFormatter : IAuditFormatter
+{
+ private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false };
+
+ public void WritePage(IReadOnlyList events, TextWriter output)
+ {
+ foreach (var evt in events)
+ output.WriteLine(JsonSerializer.Serialize(evt, Compact));
+ }
+}
+
+///
+/// Resolves an for a given --format value:
+/// table renders a column-aligned text table (),
+/// any other value (including json) renders JSONL.
+///
+public static class AuditFormatterFactory
+{
+ public static IAuditFormatter Create(string format, TextWriter notices)
+ {
+ if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
+ return new TableAuditFormatter();
+
+ return new JsonLinesAuditFormatter();
+ }
+}
diff --git a/src/ScadaLink.CLI/Commands/AuditLogCommands.cs b/src/ScadaLink.CLI/Commands/AuditLogCommands.cs
index 00fc084..eee6143 100644
--- a/src/ScadaLink.CLI/Commands/AuditLogCommands.cs
+++ b/src/ScadaLink.CLI/Commands/AuditLogCommands.cs
@@ -4,11 +4,50 @@ using ScadaLink.Commons.Messages.Management;
namespace ScadaLink.CLI.Commands;
+///
+/// The scadalink audit-config command group: views the configuration-change
+/// audit log (the IAuditService trail of admin edits — distinct from the
+/// centralized append-only Audit Log served by ).
+///
+///
+/// Renamed from audit-log in #23 M8-T7 to avoid confusion with the new
+/// scadalink audit group. The old audit-log name is retained as a
+/// deprecated alias; still resolves the full subcommand
+/// tree, and Program.cs prints a deprecation warning when it is used.
+///
public static class AuditLogCommands
{
+ /// The deprecated alias kept for backward compatibility with the old command name.
+ public const string DeprecatedAlias = "audit-log";
+
+ /// The deprecation warning emitted when the old audit-log name is used.
+ public const string DeprecationWarning =
+ "Warning: 'audit-log' is deprecated and will be removed in a future release. "
+ + "Use 'audit-config' instead.";
+
+ ///
+ /// Writes the to when the
+ /// CLI was invoked via the deprecated audit-log command name (i.e. the first
+ /// argument is ). The command itself still works — it is
+ /// an alias of audit-config — so this only adds the migration warning.
+ /// Factored out of Program.cs so it is unit-testable without spawning a process.
+ ///
+ public static void WriteDeprecationWarningIfNeeded(string[] args, TextWriter stderr)
+ {
+ if (args.Length > 0
+ && string.Equals(args[0], DeprecatedAlias, StringComparison.Ordinal))
+ {
+ stderr.WriteLine(DeprecationWarning);
+ }
+ }
+
public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
{
- var command = new Command("audit-log") { Description = "Query audit logs" };
+ var command = new Command("audit-config") { Description = "Query the configuration-change audit log" };
+ // Backward-compatible alias for the pre-M8 `audit-log` name. The alias keeps
+ // full subcommand parity automatically; the deprecation warning is emitted by
+ // the args[0] check in Program.cs.
+ command.Aliases.Add(DeprecatedAlias);
command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption));
diff --git a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs
new file mode 100644
index 0000000..f18f971
--- /dev/null
+++ b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs
@@ -0,0 +1,179 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace ScadaLink.CLI.Commands;
+
+///
+/// Filter arguments for an audit query invocation. Mirrors the Bundle B
+/// GET /api/audit/query filter parameters; /
+/// are time-specs (relative like 1h/7d, or absolute ISO-8601).
+///
+public sealed class AuditQueryArgs
+{
+ public string? Since { get; set; }
+ public string? Until { get; set; }
+ public string? Channel { get; set; }
+ public string? Kind { get; set; }
+ public string? Status { get; set; }
+ public string? Site { get; set; }
+ public string? Target { get; set; }
+ public string? Actor { get; set; }
+ public string? CorrelationId { get; set; }
+ public bool ErrorsOnly { get; set; }
+ public int PageSize { get; set; } = 100;
+}
+
+///
+/// Pure helpers for the audit query subcommand: time-spec resolution, query-string
+/// construction, and the keyset-cursor paging loop. Kept separate from the command wiring
+/// so each piece is unit-testable without standing up the command tree.
+///
+public static class AuditQueryHelpers
+{
+ // where unit is s/m/h/d — a relative offset back from "now".
+ private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled);
+
+ ///
+ /// Resolves a time-spec to an absolute . Accepts a
+ /// relative offset (30s, 15m, 1h, 7d) interpreted as
+ /// minus the offset, or an absolute ISO-8601 timestamp.
+ ///
+ /// The spec is neither a known relative form nor a parseable ISO-8601 timestamp.
+ public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now)
+ {
+ if (string.IsNullOrWhiteSpace(spec))
+ throw new FormatException("Empty time value.");
+
+ var match = RelativeSpec.Match(spec.Trim());
+ if (match.Success)
+ {
+ var amount = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
+ var offset = match.Groups[2].Value switch
+ {
+ "s" => TimeSpan.FromSeconds(amount),
+ "m" => TimeSpan.FromMinutes(amount),
+ "h" => TimeSpan.FromHours(amount),
+ "d" => TimeSpan.FromDays(amount),
+ _ => throw new FormatException($"Unknown time unit in '{spec}'."),
+ };
+ return now - offset;
+ }
+
+ if (DateTimeOffset.TryParse(spec, CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var absolute))
+ {
+ return absolute;
+ }
+
+ throw new FormatException(
+ $"Invalid time value '{spec}'. Use a relative offset (e.g. 1h, 24h, 7d) or an ISO-8601 timestamp.");
+ }
+
+ ///
+ /// Builds the ?... query string for GET /api/audit/query from the filter
+ /// args plus an optional keyset cursor. Unset filters are omitted. --errors-only
+ /// maps to status=Failed (the server takes a single status value).
+ ///
+ public static string BuildQueryString(
+ AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
+ {
+ var parts = new List();
+
+ void Add(string key, string? value)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ parts.Add($"{key}={Uri.EscapeDataString(value)}");
+ }
+
+ if (!string.IsNullOrWhiteSpace(args.Since))
+ Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture));
+ if (!string.IsNullOrWhiteSpace(args.Until))
+ Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture));
+
+ Add("channel", args.Channel);
+ Add("kind", args.Kind);
+
+ // --errors-only is a convenience shorthand for the single-value Failed status
+ // filter. The server's status filter accepts one value, so --errors-only and an
+ // explicit --status are mutually exclusive in effect; --errors-only wins.
+ Add("status", args.ErrorsOnly ? "Failed" : args.Status);
+
+ Add("sourceSiteId", args.Site);
+ Add("target", args.Target);
+ Add("actor", args.Actor);
+ Add("correlationId", args.CorrelationId);
+ Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
+
+ if (afterOccurredAtUtc.HasValue)
+ Add("afterOccurredAtUtc", afterOccurredAtUtc.Value.ToString("o", CultureInfo.InvariantCulture));
+ Add("afterEventId", afterEventId);
+
+ return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts);
+ }
+
+ ///
+ /// Executes the query: GETs /api/audit/query, renders each page with
+ /// , and — when is set —
+ /// follows nextCursor until the server returns a null cursor. Returns the
+ /// process exit code (0 success, non-zero on HTTP/transport error).
+ ///
+ public static async Task RunQueryAsync(
+ ManagementHttpClient client,
+ AuditQueryArgs args,
+ bool fetchAll,
+ IAuditFormatter formatter,
+ TextWriter output,
+ DateTimeOffset now)
+ {
+ DateTimeOffset? afterOccurredAtUtc = null;
+ string? afterEventId = null;
+
+ while (true)
+ {
+ var qs = BuildQueryString(args, now, afterOccurredAtUtc, afterEventId);
+ var response = await client.SendGetAsync("api/audit/query" + qs, TimeSpan.FromSeconds(30));
+
+ if (response.JsonData == null)
+ {
+ OutputFormatter.WriteError(
+ response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR");
+ return 1;
+ }
+
+ using var doc = JsonDocument.Parse(response.JsonData);
+ var root = doc.RootElement;
+
+ var events = root.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array
+ ? evts.EnumerateArray().ToList()
+ : new List();
+ formatter.WritePage(events, output);
+ output.Flush();
+
+ if (!fetchAll)
+ return 0;
+
+ if (!root.TryGetProperty("nextCursor", out var cursor)
+ || cursor.ValueKind != JsonValueKind.Object)
+ {
+ return 0;
+ }
+
+ afterOccurredAtUtc = cursor.TryGetProperty("afterOccurredAtUtc", out var c1)
+ && c1.ValueKind == JsonValueKind.String
+ ? DateTimeOffset.Parse(c1.GetString()!, CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal)
+ : null;
+ afterEventId = cursor.TryGetProperty("afterEventId", out var c2)
+ && c2.ValueKind == JsonValueKind.String
+ ? c2.GetString()
+ : null;
+
+ // A malformed cursor (object present but missing both keys) would loop
+ // forever — treat it as the end of results.
+ if (afterOccurredAtUtc == null && afterEventId == null)
+ return 0;
+ }
+ }
+}
diff --git a/src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs b/src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs
new file mode 100644
index 0000000..45aa964
--- /dev/null
+++ b/src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs
@@ -0,0 +1,20 @@
+using System.Globalization;
+
+namespace ScadaLink.CLI.Commands;
+
+///
+/// Helpers for the audit verify-chain subcommand. v1 is a no-op: hash-chain
+/// tamper-evidence is deferred to v1.x (see Component-AuditLog.md). The command still
+/// validates its --month argument so the surface is stable for v1.x.
+///
+public static class AuditVerifyChainHelpers
+{
+ ///
+ /// Returns true if is a well-formed YYYY-MM value
+ /// with a real month (01-12). A malformed month (e.g. 2026-13) is rejected.
+ ///
+ public static bool IsValidMonth(string? month)
+ => !string.IsNullOrWhiteSpace(month)
+ && DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out _);
+}
diff --git a/src/ScadaLink.CLI/Commands/TableAuditFormatter.cs b/src/ScadaLink.CLI/Commands/TableAuditFormatter.cs
new file mode 100644
index 0000000..98c9b6c
--- /dev/null
+++ b/src/ScadaLink.CLI/Commands/TableAuditFormatter.cs
@@ -0,0 +1,96 @@
+using System.Text.Json;
+
+namespace ScadaLink.CLI.Commands;
+
+///
+/// Human-readable table formatter for audit query --format table (Audit Log
+/// #23 M8-T6). Renders each fetched page as a column-aligned text table with a fixed
+/// column set (). Long free-text fields (Target, Actor) are
+/// truncated with an ellipsis so columns stay aligned regardless of payload size.
+///
+///
+/// A header row is emitted once per page (matching the streamable, page-at-a-time
+/// contract of ). An empty page emits the header only,
+/// so the column shape is visible even with zero results.
+///
+public sealed class TableAuditFormatter : IAuditFormatter
+{
+ /// JSON property name (camelCase, as the server serializes it) → column header.
+ private static readonly (string Property, string Header, int MaxWidth)[] Columns =
+ {
+ ("occurredAtUtc", "OccurredAtUtc", 24),
+ ("channel", "Channel", 14),
+ ("kind", "Kind", 18),
+ ("status", "Status", 12),
+ ("target", "Target", 32),
+ ("actor", "Actor", 20),
+ ("durationMs", "DurationMs", 10),
+ ("httpStatus", "HttpStatus", 10),
+ };
+
+ public void WritePage(IReadOnlyList events, TextWriter output)
+ {
+ // Build every cell first so column widths account for the actual data.
+ var rows = new List(events.Count);
+ foreach (var evt in events)
+ {
+ var cells = new string[Columns.Length];
+ for (var i = 0; i < Columns.Length; i++)
+ cells[i] = Truncate(CellValue(evt, Columns[i].Property), Columns[i].MaxWidth);
+ rows.Add(cells);
+ }
+
+ var widths = new int[Columns.Length];
+ for (var i = 0; i < Columns.Length; i++)
+ widths[i] = Columns[i].Header.Length;
+ foreach (var row in rows)
+ for (var i = 0; i < Columns.Length; i++)
+ widths[i] = Math.Max(widths[i], row[i].Length);
+
+ WriteRow(output, Columns.Select(c => c.Header).ToArray(), widths);
+ foreach (var row in rows)
+ WriteRow(output, row, widths);
+ }
+
+ ///
+ /// Extracts a cell value for from an audit event.
+ /// A missing property or a JSON null renders as an empty string (never
+ /// the literal text "null").
+ ///
+ private static string CellValue(JsonElement evt, string property)
+ {
+ if (evt.ValueKind != JsonValueKind.Object
+ || !evt.TryGetProperty(property, out var value)
+ || value.ValueKind == JsonValueKind.Null)
+ {
+ return string.Empty;
+ }
+
+ return value.ValueKind == JsonValueKind.String
+ ? value.GetString() ?? string.Empty
+ : value.ToString();
+ }
+
+ ///
+ /// Truncates to characters,
+ /// replacing the tail with a single-character ellipsis so the column stays aligned.
+ ///
+ private static string Truncate(string value, int maxWidth)
+ {
+ if (maxWidth <= 0 || value.Length <= maxWidth)
+ return value;
+ if (maxWidth == 1)
+ return "…";
+ return value.Substring(0, maxWidth - 1) + "…";
+ }
+
+ private static void WriteRow(TextWriter output, IReadOnlyList cells, int[] widths)
+ {
+ for (var i = 0; i < cells.Count; i++)
+ {
+ // Last column is not padded — avoids trailing whitespace at line end.
+ output.Write(i == cells.Count - 1 ? cells[i] : cells[i].PadRight(widths[i] + 2));
+ }
+ output.WriteLine();
+ }
+}
diff --git a/src/ScadaLink.CLI/ManagementHttpClient.cs b/src/ScadaLink.CLI/ManagementHttpClient.cs
index 515bf42..ce8e02b 100644
--- a/src/ScadaLink.CLI/ManagementHttpClient.cs
+++ b/src/ScadaLink.CLI/ManagementHttpClient.cs
@@ -74,6 +74,65 @@ public class ManagementHttpClient : IDisposable
return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
}
+ ///
+ /// Issues a plain HTTP GET against a REST endpoint (e.g. the audit
+ /// /api/audit/query endpoint introduced by Audit Log #23 M8) and returns the
+ /// response body. Unlike , this does not wrap the call
+ /// in the POST /management command envelope — the audit endpoints are plain
+ /// REST resources. Authentication (HTTP Basic) and the base address are shared.
+ ///
+ /// Path relative to the base URL, with query string.
+ public async Task SendGetAsync(string relativePath, TimeSpan timeout)
+ {
+ using var cts = new CancellationTokenSource(timeout);
+
+ HttpResponseMessage httpResponse;
+ try
+ {
+ httpResponse = await _httpClient.GetAsync(relativePath, cts.Token);
+ }
+ catch (TaskCanceledException)
+ {
+ return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
+ }
+ catch (HttpRequestException ex)
+ {
+ return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED");
+ }
+
+ var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token);
+
+ if (httpResponse.IsSuccessStatusCode)
+ {
+ return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null);
+ }
+
+ string? error = null;
+ string? code = null;
+ try
+ {
+ using var doc = JsonDocument.Parse(responseBody);
+ error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
+ code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
+ }
+ catch
+ {
+ error = responseBody;
+ }
+
+ return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
+ }
+
+ ///
+ /// Issues a plain HTTP GET and returns the raw
+ /// so the caller can stream the response body without buffering it in memory — used
+ /// by audit export, where the response can be many megabytes. The caller owns
+ /// disposing the returned message. The
+ /// option ensures the body is not pre-buffered.
+ ///
+ public async Task SendGetStreamAsync(string relativePath, CancellationToken cancellationToken)
+ => await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+
public void Dispose() => _httpClient.Dispose();
}
diff --git a/src/ScadaLink.CLI/Program.cs b/src/ScadaLink.CLI/Program.cs
index 08da7d7..240b410 100644
--- a/src/ScadaLink.CLI/Program.cs
+++ b/src/ScadaLink.CLI/Program.cs
@@ -27,6 +27,7 @@ rootCommand.Add(ExternalSystemCommands.Build(urlOption, formatOption, usernameOp
rootCommand.Add(NotificationCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(AuditLogCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
+rootCommand.Add(AuditCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(HealthCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
@@ -38,5 +39,10 @@ rootCommand.SetAction(_ =>
Console.WriteLine("Use --help to see available commands.");
});
+// Deprecation notice for the pre-M8 `audit-log` command name. The command itself
+// still works (it is an alias of `audit-config`), but using the old name emits a
+// warning to stderr so scripts can be migrated.
+AuditLogCommands.WriteDeprecationWarningIfNeeded(args, Console.Error);
+
var parseResult = CommandLineParser.Parse(rootCommand, args);
return await parseResult.InvokeAsync();
diff --git a/src/ScadaLink.CLI/README.md b/src/ScadaLink.CLI/README.md
index 134af60..eed1ccf 100644
--- a/src/ScadaLink.CLI/README.md
+++ b/src/ScadaLink.CLI/README.md
@@ -1049,14 +1049,118 @@ Features:
---
-### `audit-log` — Audit log queries
+### `audit` — Centralized Audit Log
-#### `audit-log query`
+Read access to the central append-only **Audit Log** (#23) — the record of every
+script-trust-boundary action: outbound API calls (sync + cached), outbound DB
+operations (sync + cached), notifications, and inbound API calls. This is distinct
+from the configuration-change audit trail exposed by [`audit-config`](#audit-config--configuration-change-audit-log).
-Query the central audit log with optional filters and pagination.
+The subcommands map directly onto the `GET /api/audit/query` and
+`GET /api/audit/export` management endpoints. Filters and the result columns mirror
+the Central UI **Audit** page, so a CLI query and a UI query with the same filters
+return the same rows — CLI ↔ UI filter parity is intentional.
+
+**Permissions.** Querying requires the `OperationalAudit` permission (roles `Admin`,
+`Audit`, or `AuditReadOnly`). Exporting requires the stricter `AuditExport` permission
+(roles `Admin` or `Audit`) — read access does *not* imply export access. A request
+without the required role returns exit code `2`.
+
+#### `audit query`
+
+Query audit log events with optional filters and keyset pagination.
```sh
-scadalink --url audit-log query [options]
+scadalink --url audit query [options]
+```
+
+| Option | Required | Default | Description |
+|--------|----------|---------|-------------|
+| `--since` | no | — | Start time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
+| `--until` | no | — | End time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
+| `--channel` | no | — | Filter by channel (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`) |
+| `--kind` | no | — | Filter by event kind (`ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`) |
+| `--status` | no | — | Filter by status (`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`) |
+| `--site` | no | — | Filter by source site ID |
+| `--target` | no | — | Filter by target (external system, DB connection, notification list) |
+| `--actor` | no | — | Filter by actor |
+| `--correlation-id` | no | — | Filter by correlation ID |
+| `--errors-only` | no | `false` | Show only failed events (`status=Failed`; overrides `--status`) |
+| `--page-size` | no | `100` | Events per page (1–1000) |
+| `--all` | no | `false` | Fetch every page, following the keyset cursor |
+| `--format` | no | `json` | Output format: `json` (JSONL, one event per line) or `table` |
+
+With `--format table`, events render as an aligned text table with columns
+`OccurredAtUtc`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`,
+`HttpStatus`; long `Target`/`Actor` values are truncated with an ellipsis. With
+`--format json` (the default), each page is emitted as JSONL — one JSON object per
+line — which streams cleanly under `--all` across many pages.
+
+#### `audit export`
+
+Export audit log events to a file. The export streams from the server, so it is not
+bounded by the query page size.
+
+```sh
+scadalink --url audit export --since