using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Policy.Engine.IncrementalOrchestrator;
///
/// Types of policy-relevant changes that trigger re-evaluation.
///
public enum PolicyChangeType
{
/// Advisory was created or updated.
AdvisoryUpdated,
/// Advisory was retracted/withdrawn.
AdvisoryRetracted,
/// VEX statement was added or modified.
VexStatementUpdated,
/// VEX conflict detected.
VexConflictDetected,
/// SBOM was ingested or updated.
SbomUpdated,
/// SBOM component changed.
SbomComponentChanged,
/// Policy version was published.
PolicyVersionPublished,
/// Manual re-evaluation triggered.
ManualTrigger
}
///
/// Priority levels for change processing.
///
public enum PolicyChangePriority
{
/// Normal priority - standard processing.
Normal = 0,
/// High priority - process sooner.
High = 1,
/// Emergency - immediate processing (e.g., KEV addition).
Emergency = 2
}
///
/// Represents a change event that may trigger policy re-evaluation.
///
public sealed record PolicyChangeEvent
{
///
/// Unique event identifier (deterministic based on content).
///
public required string EventId { get; init; }
///
/// Type of change.
///
public required PolicyChangeType ChangeType { get; init; }
///
/// Tenant context for the change.
///
public required string TenantId { get; init; }
///
/// Timestamp when the change occurred (from source system).
///
public required DateTimeOffset OccurredAt { get; init; }
///
/// Timestamp when the event was created.
///
public required DateTimeOffset CreatedAt { get; init; }
///
/// Processing priority.
///
public required PolicyChangePriority Priority { get; init; }
///
/// Source system that produced the change.
///
public required string Source { get; init; }
///
/// Correlation ID for tracing.
///
public string? CorrelationId { get; init; }
///
/// Advisory ID (for advisory/VEX changes).
///
public string? AdvisoryId { get; init; }
///
/// Vulnerability ID (CVE, GHSA, etc.).
///
public string? VulnerabilityId { get; init; }
///
/// Affected PURLs (package URLs).
///
public ImmutableArray AffectedPurls { get; init; } = ImmutableArray.Empty;
///
/// Affected product keys (for SBOM targeting).
///
public ImmutableArray AffectedProductKeys { get; init; } = ImmutableArray.Empty;
///
/// Affected SBOM IDs (for direct targeting).
///
public ImmutableArray AffectedSbomIds { get; init; } = ImmutableArray.Empty;
///
/// Policy IDs to re-evaluate (empty = all applicable).
///
public ImmutableArray PolicyIds { get; init; } = ImmutableArray.Empty;
///
/// Additional metadata for the change.
///
public ImmutableDictionary Metadata { get; init; } =
ImmutableDictionary.Empty;
///
/// Content hash for deduplication.
///
public required string ContentHash { get; init; }
///
/// Computes a deterministic content hash for deduplication.
///
public static string ComputeContentHash(
PolicyChangeType changeType,
string tenantId,
string? advisoryId,
string? vulnerabilityId,
IEnumerable? affectedPurls,
IEnumerable? affectedProductKeys,
IEnumerable? affectedSbomIds)
{
var builder = new StringBuilder();
builder.Append(changeType.ToString()).Append('|');
builder.Append(tenantId.ToLowerInvariant()).Append('|');
builder.Append(advisoryId ?? string.Empty).Append('|');
builder.Append(vulnerabilityId ?? string.Empty).Append('|');
// Deterministic ordering
var purls = (affectedPurls ?? Enumerable.Empty())
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim())
.OrderBy(p => p, StringComparer.Ordinal);
var productKeys = (affectedProductKeys ?? Enumerable.Empty())
.Where(k => !string.IsNullOrWhiteSpace(k))
.Select(k => k.Trim())
.OrderBy(k => k, StringComparer.Ordinal);
var sbomIds = (affectedSbomIds ?? Enumerable.Empty())
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.Trim())
.OrderBy(s => s, StringComparer.Ordinal);
foreach (var purl in purls)
{
builder.Append("purl:").Append(purl).Append('|');
}
foreach (var key in productKeys)
{
builder.Append("pk:").Append(key).Append('|');
}
foreach (var id in sbomIds)
{
builder.Append("sbom:").Append(id).Append('|');
}
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return Convert.ToHexStringLower(bytes);
}
///
/// Creates a deterministic event ID.
///
public static string CreateEventId(
string tenantId,
PolicyChangeType changeType,
string source,
DateTimeOffset occurredAt,
string contentHash)
{
var seed = $"{tenantId}|{changeType}|{source}|{occurredAt:O}|{contentHash}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"pce-{Convert.ToHexStringLower(bytes)[..16]}";
}
}
///
/// Factory for creating normalized policy change events.
///
public static class PolicyChangeEventFactory
{
///
/// Creates an advisory update event.
///
public static PolicyChangeEvent CreateAdvisoryUpdated(
string tenantId,
string advisoryId,
string? vulnerabilityId,
IEnumerable affectedPurls,
string source,
DateTimeOffset occurredAt,
DateTimeOffset createdAt,
PolicyChangePriority priority = PolicyChangePriority.Normal,
string? correlationId = null,
ImmutableDictionary? metadata = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
var normalizedAdvisoryId = Normalize(advisoryId, nameof(advisoryId));
var normalizedVulnId = vulnerabilityId?.Trim();
var normalizedPurls = NormalizePurls(affectedPurls);
var contentHash = PolicyChangeEvent.ComputeContentHash(
PolicyChangeType.AdvisoryUpdated,
normalizedTenant,
normalizedAdvisoryId,
normalizedVulnId,
normalizedPurls,
null,
null);
var eventId = PolicyChangeEvent.CreateEventId(
normalizedTenant,
PolicyChangeType.AdvisoryUpdated,
source,
occurredAt,
contentHash);
return new PolicyChangeEvent
{
EventId = eventId,
ChangeType = PolicyChangeType.AdvisoryUpdated,
TenantId = normalizedTenant,
OccurredAt = occurredAt,
CreatedAt = createdAt,
Priority = priority,
Source = source,
CorrelationId = correlationId,
AdvisoryId = normalizedAdvisoryId,
VulnerabilityId = normalizedVulnId,
AffectedPurls = normalizedPurls,
ContentHash = contentHash,
Metadata = metadata ?? ImmutableDictionary.Empty
};
}
///
/// Creates a VEX statement update event.
///
public static PolicyChangeEvent CreateVexUpdated(
string tenantId,
string vulnerabilityId,
IEnumerable affectedProductKeys,
string source,
DateTimeOffset occurredAt,
DateTimeOffset createdAt,
PolicyChangePriority priority = PolicyChangePriority.Normal,
string? correlationId = null,
ImmutableDictionary? metadata = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
var normalizedVulnId = Normalize(vulnerabilityId, nameof(vulnerabilityId));
var normalizedKeys = NormalizeProductKeys(affectedProductKeys);
var contentHash = PolicyChangeEvent.ComputeContentHash(
PolicyChangeType.VexStatementUpdated,
normalizedTenant,
null,
normalizedVulnId,
null,
normalizedKeys,
null);
var eventId = PolicyChangeEvent.CreateEventId(
normalizedTenant,
PolicyChangeType.VexStatementUpdated,
source,
occurredAt,
contentHash);
return new PolicyChangeEvent
{
EventId = eventId,
ChangeType = PolicyChangeType.VexStatementUpdated,
TenantId = normalizedTenant,
OccurredAt = occurredAt,
CreatedAt = createdAt,
Priority = priority,
Source = source,
CorrelationId = correlationId,
VulnerabilityId = normalizedVulnId,
AffectedProductKeys = normalizedKeys,
ContentHash = contentHash,
Metadata = metadata ?? ImmutableDictionary.Empty
};
}
///
/// Creates an SBOM update event.
///
public static PolicyChangeEvent CreateSbomUpdated(
string tenantId,
string sbomId,
string productKey,
IEnumerable componentPurls,
string source,
DateTimeOffset occurredAt,
DateTimeOffset createdAt,
PolicyChangePriority priority = PolicyChangePriority.Normal,
string? correlationId = null,
ImmutableDictionary? metadata = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
var normalizedSbomId = Normalize(sbomId, nameof(sbomId));
var normalizedProductKey = Normalize(productKey, nameof(productKey));
var normalizedPurls = NormalizePurls(componentPurls);
var contentHash = PolicyChangeEvent.ComputeContentHash(
PolicyChangeType.SbomUpdated,
normalizedTenant,
null,
null,
normalizedPurls,
ImmutableArray.Create(normalizedProductKey),
ImmutableArray.Create(normalizedSbomId));
var eventId = PolicyChangeEvent.CreateEventId(
normalizedTenant,
PolicyChangeType.SbomUpdated,
source,
occurredAt,
contentHash);
return new PolicyChangeEvent
{
EventId = eventId,
ChangeType = PolicyChangeType.SbomUpdated,
TenantId = normalizedTenant,
OccurredAt = occurredAt,
CreatedAt = createdAt,
Priority = priority,
Source = source,
CorrelationId = correlationId,
AffectedPurls = normalizedPurls,
AffectedProductKeys = ImmutableArray.Create(normalizedProductKey),
AffectedSbomIds = ImmutableArray.Create(normalizedSbomId),
ContentHash = contentHash,
Metadata = metadata ?? ImmutableDictionary.Empty
};
}
///
/// Creates a manual trigger event.
///
public static PolicyChangeEvent CreateManualTrigger(
string tenantId,
IEnumerable? policyIds,
IEnumerable? sbomIds,
IEnumerable? productKeys,
string requestedBy,
DateTimeOffset createdAt,
PolicyChangePriority priority = PolicyChangePriority.Normal,
string? correlationId = null,
ImmutableDictionary? metadata = null)
{
var normalizedTenant = NormalizeTenant(tenantId);
var normalizedPolicyIds = NormalizePolicyIds(policyIds);
var normalizedSbomIds = NormalizeSbomIds(sbomIds);
var normalizedProductKeys = NormalizeProductKeys(productKeys);
var contentHash = PolicyChangeEvent.ComputeContentHash(
PolicyChangeType.ManualTrigger,
normalizedTenant,
null,
null,
null,
normalizedProductKeys,
normalizedSbomIds);
var eventId = PolicyChangeEvent.CreateEventId(
normalizedTenant,
PolicyChangeType.ManualTrigger,
"manual",
createdAt,
contentHash);
return new PolicyChangeEvent
{
EventId = eventId,
ChangeType = PolicyChangeType.ManualTrigger,
TenantId = normalizedTenant,
OccurredAt = createdAt,
CreatedAt = createdAt,
Priority = priority,
Source = "manual",
CorrelationId = correlationId,
PolicyIds = normalizedPolicyIds,
AffectedProductKeys = normalizedProductKeys,
AffectedSbomIds = normalizedSbomIds,
ContentHash = contentHash,
Metadata = (metadata ?? ImmutableDictionary.Empty)
.SetItem("requestedBy", requestedBy)
};
}
private static string NormalizeTenant(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new ArgumentException("Tenant ID cannot be null or whitespace", nameof(tenantId));
}
return tenantId.Trim().ToLowerInvariant();
}
private static string Normalize(string value, string name)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"{name} cannot be null or whitespace", name);
}
return value.Trim();
}
private static ImmutableArray NormalizePurls(IEnumerable? purls)
{
return (purls ?? Enumerable.Empty())
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(p => p, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray NormalizeProductKeys(IEnumerable? keys)
{
return (keys ?? Enumerable.Empty())
.Where(k => !string.IsNullOrWhiteSpace(k))
.Select(k => k.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static ImmutableArray NormalizeSbomIds(IEnumerable? ids)
{
return (ids ?? Enumerable.Empty())
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray NormalizePolicyIds(IEnumerable? ids)
{
return (ids ?? Enumerable.Empty())
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
}
///
/// A batch of change events to be processed together.
///
public sealed record PolicyChangeBatch
{
///
/// Unique batch identifier.
///
public required string BatchId { get; init; }
///
/// Tenant context.
///
public required string TenantId { get; init; }
///
/// Events in this batch (deterministically ordered).
///
public required ImmutableArray Events { get; init; }
///
/// Highest priority in the batch.
///
public required PolicyChangePriority Priority { get; init; }
///
/// When the batch was created.
///
public required DateTimeOffset CreatedAt { get; init; }
///
/// Combined affected PURLs from all events.
///
public required ImmutableArray AffectedPurls { get; init; }
///
/// Combined affected product keys from all events.
///
public required ImmutableArray AffectedProductKeys { get; init; }
///
/// Combined affected SBOM IDs from all events.
///
public required ImmutableArray AffectedSbomIds { get; init; }
///
/// Combined vulnerability IDs from all events.
///
public required ImmutableArray VulnerabilityIds { get; init; }
}