295 lines
8.8 KiB
C#
295 lines
8.8 KiB
C#
using System.Collections.Immutable;
|
|
using System.Globalization;
|
|
|
|
namespace StellaOps.Policy.Exceptions.Models;
|
|
|
|
/// <summary>
|
|
/// Type of exception lifecycle event.
|
|
/// </summary>
|
|
public enum ExceptionEventType
|
|
{
|
|
/// <summary>Exception was created (Proposed status).</summary>
|
|
Created,
|
|
|
|
/// <summary>Exception details were updated.</summary>
|
|
Updated,
|
|
|
|
/// <summary>Exception was approved by an approver.</summary>
|
|
Approved,
|
|
|
|
/// <summary>Exception was activated (Active status).</summary>
|
|
Activated,
|
|
|
|
/// <summary>Exception expiry was extended.</summary>
|
|
Extended,
|
|
|
|
/// <summary>Exception was explicitly revoked.</summary>
|
|
Revoked,
|
|
|
|
/// <summary>Exception expired automatically.</summary>
|
|
Expired,
|
|
|
|
/// <summary>Evidence was attached to exception.</summary>
|
|
EvidenceAttached,
|
|
|
|
/// <summary>Compensating control was added.</summary>
|
|
CompensatingControlAdded,
|
|
|
|
/// <summary>Exception was rejected before approval.</summary>
|
|
Rejected
|
|
}
|
|
|
|
/// <summary>
|
|
/// Immutable event recording a state change in an exception's lifecycle.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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)
|
|
/// </remarks>
|
|
public sealed record ExceptionEvent
|
|
{
|
|
/// <summary>
|
|
/// Unique event identifier.
|
|
/// </summary>
|
|
public required Guid EventId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Reference to the parent exception.
|
|
/// </summary>
|
|
public required string ExceptionId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Sequence number within this exception's event stream.
|
|
/// </summary>
|
|
public required int SequenceNumber { get; init; }
|
|
|
|
/// <summary>
|
|
/// Type of event that occurred.
|
|
/// </summary>
|
|
public required ExceptionEventType EventType { get; init; }
|
|
|
|
/// <summary>
|
|
/// Identity of the actor who triggered this event.
|
|
/// May be a user ID, service account, or "system" for automated events.
|
|
/// </summary>
|
|
public required string ActorId { get; init; }
|
|
|
|
/// <summary>
|
|
/// When this event occurred.
|
|
/// </summary>
|
|
public required DateTimeOffset OccurredAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// Status before this event (null for Created events).
|
|
/// </summary>
|
|
public ExceptionStatus? PreviousStatus { get; init; }
|
|
|
|
/// <summary>
|
|
/// Status after this event.
|
|
/// </summary>
|
|
public required ExceptionStatus NewStatus { get; init; }
|
|
|
|
/// <summary>
|
|
/// Version number after this event.
|
|
/// </summary>
|
|
public required int NewVersion { get; init; }
|
|
|
|
/// <summary>
|
|
/// Human-readable description of what happened.
|
|
/// </summary>
|
|
public string? Description { get; init; }
|
|
|
|
/// <summary>
|
|
/// Additional structured details about the event.
|
|
/// </summary>
|
|
public ImmutableDictionary<string, string> Details { get; init; } =
|
|
ImmutableDictionary<string, string>.Empty;
|
|
|
|
/// <summary>
|
|
/// IP address or client identifier of the actor (for audit).
|
|
/// </summary>
|
|
public string? ClientInfo { get; init; }
|
|
|
|
/// <summary>
|
|
/// Creates a "Created" event for a new exception.
|
|
/// </summary>
|
|
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
|
|
};
|
|
|
|
/// <summary>
|
|
/// Creates an "Approved" event.
|
|
/// </summary>
|
|
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
|
|
};
|
|
|
|
/// <summary>
|
|
/// Creates an "Activated" event.
|
|
/// </summary>
|
|
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
|
|
};
|
|
|
|
/// <summary>
|
|
/// Creates a "Revoked" event.
|
|
/// </summary>
|
|
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<string, string>.Empty.Add("reason", reason),
|
|
ClientInfo = clientInfo
|
|
};
|
|
|
|
/// <summary>
|
|
/// Creates an "Expired" event (typically from system).
|
|
/// </summary>
|
|
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"
|
|
};
|
|
|
|
/// <summary>
|
|
/// Creates an "Extended" event.
|
|
/// </summary>
|
|
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<string, string>.Empty
|
|
.Add("previous_expiry", previousExpiry.ToString("O", CultureInfo.InvariantCulture))
|
|
.Add("new_expiry", newExpiry.ToString("O", CultureInfo.InvariantCulture)),
|
|
ClientInfo = clientInfo
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Aggregated exception history for audit display.
|
|
/// </summary>
|
|
public sealed record ExceptionHistory
|
|
{
|
|
/// <summary>
|
|
/// The exception this history belongs to.
|
|
/// </summary>
|
|
public required string ExceptionId { get; init; }
|
|
|
|
/// <summary>
|
|
/// All events in chronological order.
|
|
/// </summary>
|
|
public required ImmutableArray<ExceptionEvent> Events { get; init; }
|
|
|
|
/// <summary>
|
|
/// Total number of events.
|
|
/// </summary>
|
|
public int EventCount => Events.Length;
|
|
|
|
/// <summary>
|
|
/// When the exception was first created.
|
|
/// </summary>
|
|
public DateTimeOffset? FirstEventAt => Events.Length > 0 ? Events[0].OccurredAt : null;
|
|
|
|
/// <summary>
|
|
/// When the last event occurred.
|
|
/// </summary>
|
|
public DateTimeOffset? LastEventAt => Events.Length > 0 ? Events[^1].OccurredAt : null;
|
|
}
|