using System.Collections.Immutable; using System.Globalization; namespace StellaOps.Policy.Exceptions.Models; /// /// Type of exception lifecycle event. /// public enum ExceptionEventType { /// Exception was created (Proposed status). Created, /// Exception details were updated. Updated, /// Exception was approved by an approver. Approved, /// Exception was activated (Active status). Activated, /// Exception expiry was extended. Extended, /// Exception was explicitly revoked. Revoked, /// Exception expired automatically. Expired, /// Evidence was attached to exception. EvidenceAttached, /// Compensating control was added. CompensatingControlAdded, /// Exception was rejected before approval. Rejected } /// /// Immutable event recording a state change in an exception's lifecycle. /// /// /// Exception events form an append-only audit trail that can never be modified. /// Each event captures: /// - What happened (event type) /// - Who did it (actor) /// - When it happened (timestamp) /// - What changed (before/after states) /// - Why it happened (details) /// public sealed record ExceptionEvent { /// /// Unique event identifier. /// public required Guid EventId { get; init; } /// /// Reference to the parent exception. /// public required string ExceptionId { get; init; } /// /// Sequence number within this exception's event stream. /// public required int SequenceNumber { get; init; } /// /// Type of event that occurred. /// public required ExceptionEventType EventType { get; init; } /// /// Identity of the actor who triggered this event. /// May be a user ID, service account, or "system" for automated events. /// public required string ActorId { get; init; } /// /// When this event occurred. /// public required DateTimeOffset OccurredAt { get; init; } /// /// Status before this event (null for Created events). /// public ExceptionStatus? PreviousStatus { get; init; } /// /// Status after this event. /// public required ExceptionStatus NewStatus { get; init; } /// /// Version number after this event. /// public required int NewVersion { get; init; } /// /// Human-readable description of what happened. /// public string? Description { get; init; } /// /// Additional structured details about the event. /// public ImmutableDictionary Details { get; init; } = ImmutableDictionary.Empty; /// /// IP address or client identifier of the actor (for audit). /// public string? ClientInfo { get; init; } /// /// Creates a "Created" event for a new exception. /// public static ExceptionEvent ForCreated( string exceptionId, string actorId, string? description = null, string? clientInfo = null) => new() { EventId = Guid.NewGuid(), ExceptionId = exceptionId, SequenceNumber = 1, EventType = ExceptionEventType.Created, ActorId = actorId, OccurredAt = DateTimeOffset.UtcNow, PreviousStatus = null, NewStatus = ExceptionStatus.Proposed, NewVersion = 1, Description = description ?? "Exception created", ClientInfo = clientInfo }; /// /// Creates an "Approved" event. /// public static ExceptionEvent ForApproved( string exceptionId, int sequenceNumber, string actorId, int newVersion, string? description = null, string? clientInfo = null) => new() { EventId = Guid.NewGuid(), ExceptionId = exceptionId, SequenceNumber = sequenceNumber, EventType = ExceptionEventType.Approved, ActorId = actorId, OccurredAt = DateTimeOffset.UtcNow, PreviousStatus = ExceptionStatus.Proposed, NewStatus = ExceptionStatus.Approved, NewVersion = newVersion, Description = description ?? $"Exception approved by {actorId}", ClientInfo = clientInfo }; /// /// Creates an "Activated" event. /// public static ExceptionEvent ForActivated( string exceptionId, int sequenceNumber, string actorId, int newVersion, ExceptionStatus previousStatus, string? description = null, string? clientInfo = null) => new() { EventId = Guid.NewGuid(), ExceptionId = exceptionId, SequenceNumber = sequenceNumber, EventType = ExceptionEventType.Activated, ActorId = actorId, OccurredAt = DateTimeOffset.UtcNow, PreviousStatus = previousStatus, NewStatus = ExceptionStatus.Active, NewVersion = newVersion, Description = description ?? "Exception activated", ClientInfo = clientInfo }; /// /// Creates a "Revoked" event. /// public static ExceptionEvent ForRevoked( string exceptionId, int sequenceNumber, string actorId, int newVersion, ExceptionStatus previousStatus, string reason, string? clientInfo = null) => new() { EventId = Guid.NewGuid(), ExceptionId = exceptionId, SequenceNumber = sequenceNumber, EventType = ExceptionEventType.Revoked, ActorId = actorId, OccurredAt = DateTimeOffset.UtcNow, PreviousStatus = previousStatus, NewStatus = ExceptionStatus.Revoked, NewVersion = newVersion, Description = $"Exception revoked: {reason}", Details = ImmutableDictionary.Empty.Add("reason", reason), ClientInfo = clientInfo }; /// /// Creates an "Expired" event (typically from system). /// public static ExceptionEvent ForExpired( string exceptionId, int sequenceNumber, int newVersion) => new() { EventId = Guid.NewGuid(), ExceptionId = exceptionId, SequenceNumber = sequenceNumber, EventType = ExceptionEventType.Expired, ActorId = "system", OccurredAt = DateTimeOffset.UtcNow, PreviousStatus = ExceptionStatus.Active, NewStatus = ExceptionStatus.Expired, NewVersion = newVersion, Description = "Exception expired automatically" }; /// /// Creates an "Extended" event. /// public static ExceptionEvent ForExtended( string exceptionId, int sequenceNumber, string actorId, int newVersion, DateTimeOffset previousExpiry, DateTimeOffset newExpiry, string? reason = null, string? clientInfo = null) => new() { EventId = Guid.NewGuid(), ExceptionId = exceptionId, SequenceNumber = sequenceNumber, EventType = ExceptionEventType.Extended, ActorId = actorId, OccurredAt = DateTimeOffset.UtcNow, PreviousStatus = ExceptionStatus.Active, NewStatus = ExceptionStatus.Active, NewVersion = newVersion, Description = reason ?? $"Exception extended from {previousExpiry:O} to {newExpiry:O}", Details = ImmutableDictionary.Empty .Add("previous_expiry", previousExpiry.ToString("O", CultureInfo.InvariantCulture)) .Add("new_expiry", newExpiry.ToString("O", CultureInfo.InvariantCulture)), ClientInfo = clientInfo }; } /// /// Aggregated exception history for audit display. /// public sealed record ExceptionHistory { /// /// The exception this history belongs to. /// public required string ExceptionId { get; init; } /// /// All events in chronological order. /// public required ImmutableArray Events { get; init; } /// /// Total number of events. /// public int EventCount => Events.Length; /// /// When the exception was first created. /// public DateTimeOffset? FirstEventAt => Events.Length > 0 ? Events[0].OccurredAt : null; /// /// When the last event occurred. /// public DateTimeOffset? LastEventAt => Events.Length > 0 ? Events[^1].OccurredAt : null; }