feat(audit): M5.5 per-channel retention overrides via purge-role bounded delete (T3)

This commit is contained in:
Joseph Doherty
2026-06-16 21:47:50 -04:00
parent 55630b48b6
commit 50b674accc
13 changed files with 583 additions and 3 deletions
@@ -37,6 +37,33 @@ public sealed class AuditLogOptions
/// <summary>Central retention window in days (default 365, range [30, 3650]).</summary>
public int RetentionDays { get; set; } = 365;
/// <summary>
/// M5.5 (T3) per-channel retention overrides, keyed by the canonical channel name
/// (the <see cref="AuditChannel"/> enum name — e.g. <c>ApiOutbound</c>,
/// <c>DbOutbound</c>, <c>Notification</c>, <c>ApiInbound</c>). The value is a
/// retention window in days that MUST be SHORTER than or equal to the global
/// <see cref="RetentionDays"/>.
/// </summary>
/// <remarks>
/// <para>
/// The global <see cref="RetentionDays"/> window is enforced by month-partition
/// switch-out, which is channel-blind: it can only drop a whole month once every
/// row in it is older than the global window. A per-channel override therefore
/// can only ever expire rows EARLIER than the global purge would — never later
/// (a longer per-channel window is meaningless because the partition switch-out
/// would already have dropped the month). Overrides shorter than the global window
/// are honoured by the purge actor as a bounded, batched row DELETE on the
/// maintenance path (see <c>AuditLogPurgeActor</c>); the append-only writer/ingest
/// role is unaffected.
/// </para>
/// <para>
/// Each value is validated to be in <c>[30, RetentionDays]</c> by
/// <c>AuditLogOptionsValidator</c>; keys that are not recognized
/// <see cref="AuditChannel"/> names are rejected.
/// </para>
/// </remarks>
public Dictionary<string, int> PerChannelRetentionDays { get; set; } = new();
/// <summary>
/// Per-body byte ceiling applied to <see cref="AuditEvent.RequestSummary"/> and
/// <see cref="AuditEvent.ResponseSummary"/> for <see cref="AuditChannel.ApiInbound"/> rows
@@ -1,4 +1,5 @@
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
@@ -52,5 +53,27 @@ public sealed class AuditLogOptionsValidator : OptionsValidatorBase<AuditLogOpti
!(options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes),
$"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
$"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
// M5.5 (T3): per-channel retention overrides. Each entry must be keyed by a
// recognized AuditChannel name and carry a window in [MinRetentionDays,
// RetentionDays] — i.e. SHORTER than or equal to the global window. A longer
// per-channel window is meaningless under month-partition switch-out (governed
// by the global window), so it is rejected rather than silently ignored.
foreach (var (channelKey, days) in options.PerChannelRetentionDays)
{
builder.RequireThat(
Enum.TryParse<AuditChannel>(channelKey, ignoreCase: false, out _),
$"AuditLog:{nameof(AuditLogOptions.PerChannelRetentionDays)} key '{channelKey}' " +
$"is not a recognized channel name. Valid keys: {string.Join(", ", Enum.GetNames<AuditChannel>())}.");
// Valid when days is within [MinRetentionDays, RetentionDays] inclusive.
// The lower bound matches the global RetentionDays floor; the upper bound
// is the configured global window (longer is meaningless — see remarks).
builder.RequireThat(
!(days < MinRetentionDays || days > options.RetentionDays),
$"AuditLog:{nameof(AuditLogOptions.PerChannelRetentionDays)}['{channelKey}'] ({days}) " +
$"must be in [{MinRetentionDays}, {nameof(AuditLogOptions.RetentionDays)}={options.RetentionDays}] days " +
"— a per-channel window must be shorter than or equal to the global retention window.");
}
}
}