feat(policy): Start Epic 3900 - Exception Objects as Auditable Entities
Advisory Processing: - Processed 7 unprocessed advisories and 12 moat documents - Created advisory processing report with 3 new epic recommendations - Identified Epic 3900 (Exception Objects) as highest priority Sprint 3900.0001.0001 - 4/8 tasks completed: - T1: ExceptionObject domain model with full governance fields - T2: ExceptionEvent model for event-sourced audit trail - T4: IExceptionRepository interface with CRUD and query methods - T6: ExceptionEvaluator service with PURL pattern matching New library: StellaOps.Policy.Exceptions - Models: ExceptionObject, ExceptionScope, ExceptionEvent - Enums: ExceptionStatus, ExceptionType, ExceptionReason - Services: ExceptionEvaluator with scope matching and specificity - Repository: IExceptionRepository with filter and history support Remaining tasks: PostgreSQL schema, repository implementation, tests
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
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"))
|
||||
.Add("new_expiry", newExpiry.ToString("O")),
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user