Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,4 @@
# StellaOps.Scheduler.Models — Agent Charter
## Mission
Define Scheduler DTOs (Schedule, Run, ImpactSet, Selector, DeltaSummary) per `docs/ARCHITECTURE_SCHEDULER.md`.

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scheduler.ImpactIndex")]

View File

@@ -0,0 +1,120 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Audit log entry capturing schedule/run lifecycle events.
/// </summary>
public sealed record AuditRecord
{
public AuditRecord(
string id,
string tenantId,
string category,
string action,
DateTimeOffset occurredAt,
AuditActor actor,
string? entityId = null,
string? scheduleId = null,
string? runId = null,
string? correlationId = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
string? message = null)
: this(
id,
tenantId,
Validation.EnsureSimpleIdentifier(category, nameof(category)),
Validation.EnsureSimpleIdentifier(action, nameof(action)),
Validation.NormalizeTimestamp(occurredAt),
actor,
Validation.TrimToNull(entityId),
Validation.TrimToNull(scheduleId),
Validation.TrimToNull(runId),
Validation.TrimToNull(correlationId),
Validation.NormalizeMetadata(metadata),
Validation.TrimToNull(message))
{
}
[JsonConstructor]
public AuditRecord(
string id,
string tenantId,
string category,
string action,
DateTimeOffset occurredAt,
AuditActor actor,
string? entityId,
string? scheduleId,
string? runId,
string? correlationId,
ImmutableSortedDictionary<string, string> metadata,
string? message)
{
Id = Validation.EnsureId(id, nameof(id));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
Category = Validation.EnsureSimpleIdentifier(category, nameof(category));
Action = Validation.EnsureSimpleIdentifier(action, nameof(action));
OccurredAt = Validation.NormalizeTimestamp(occurredAt);
Actor = actor ?? throw new ArgumentNullException(nameof(actor));
EntityId = Validation.TrimToNull(entityId);
ScheduleId = Validation.TrimToNull(scheduleId);
RunId = Validation.TrimToNull(runId);
CorrelationId = Validation.TrimToNull(correlationId);
var materializedMetadata = metadata ?? ImmutableSortedDictionary<string, string>.Empty;
Metadata = materializedMetadata.Count > 0
? materializedMetadata.WithComparers(StringComparer.Ordinal)
: ImmutableSortedDictionary<string, string>.Empty;
Message = Validation.TrimToNull(message);
}
public string Id { get; }
public string TenantId { get; }
public string Category { get; }
public string Action { get; }
public DateTimeOffset OccurredAt { get; }
public AuditActor Actor { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? EntityId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ScheduleId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RunId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CorrelationId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Message { get; }
}
/// <summary>
/// Actor associated with an audit entry.
/// </summary>
public sealed record AuditActor
{
public AuditActor(string actorId, string displayName, string kind)
{
ActorId = Validation.EnsureSimpleIdentifier(actorId, nameof(actorId));
DisplayName = Validation.EnsureName(displayName, nameof(displayName));
Kind = Validation.EnsureSimpleIdentifier(kind, nameof(kind));
}
public string ActorId { get; }
public string DisplayName { get; }
public string Kind { get; }
}

View File

@@ -0,0 +1,470 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Deterministic serializer for scheduler DTOs.
/// </summary>
public static class CanonicalJsonSerializer
{
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrder = new Dictionary<Type, string[]>
{
[typeof(Schedule)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"name",
"enabled",
"cronExpression",
"timezone",
"mode",
"selection",
"onlyIf",
"notify",
"limits",
"subscribers",
"createdAt",
"createdBy",
"updatedAt",
"updatedBy",
},
[typeof(Selector)] = new[]
{
"scope",
"tenantId",
"namespaces",
"repositories",
"digests",
"includeTags",
"labels",
"resolvesTags",
},
[typeof(LabelSelector)] = new[]
{
"key",
"values",
},
[typeof(ScheduleOnlyIf)] = new[]
{
"lastReportOlderThanDays",
"policyRevision",
},
[typeof(ScheduleNotify)] = new[]
{
"onNewFindings",
"minSeverity",
"includeKev",
"includeQuietFindings",
},
[typeof(ScheduleLimits)] = new[]
{
"maxJobs",
"ratePerSecond",
"parallelism",
"burst",
},
[typeof(Run)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"scheduleId",
"trigger",
"state",
"stats",
"reason",
"createdAt",
"startedAt",
"finishedAt",
"error",
"deltas",
},
[typeof(RunStats)] = new[]
{
"candidates",
"deduped",
"queued",
"completed",
"deltas",
"newCriticals",
"newHigh",
"newMedium",
"newLow",
},
[typeof(RunReason)] = new[]
{
"manualReason",
"feedserExportId",
"vexerExportId",
"cursor",
"impactWindowFrom",
"impactWindowTo",
},
[typeof(DeltaSummary)] = new[]
{
"imageDigest",
"newFindings",
"newCriticals",
"newHigh",
"newMedium",
"newLow",
"kevHits",
"topFindings",
"reportUrl",
"attestation",
"detectedAt",
},
[typeof(DeltaFinding)] = new[]
{
"purl",
"vulnerabilityId",
"severity",
"link",
},
[typeof(ImpactSet)] = new[]
{
"schemaVersion",
"selector",
"images",
"usageOnly",
"generatedAt",
"total",
"snapshotId",
},
[typeof(ImpactImage)] = new[]
{
"imageDigest",
"registry",
"repository",
"namespaces",
"tags",
"usedByEntrypoint",
"labels",
},
[typeof(AuditRecord)] = new[]
{
"id",
"tenantId",
"category",
"action",
"occurredAt",
"actor",
"entityId",
"scheduleId",
"runId",
"correlationId",
"metadata",
"message",
},
[typeof(AuditActor)] = new[]
{
"actorId",
"displayName",
"kind",
},
[typeof(GraphBuildJob)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"sbomId",
"sbomVersionId",
"sbomDigest",
"graphSnapshotId",
"status",
"trigger",
"attempts",
"cartographerJobId",
"correlationId",
"createdAt",
"startedAt",
"completedAt",
"error",
"metadata",
},
[typeof(GraphOverlayJob)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"graphSnapshotId",
"buildJobId",
"overlayKind",
"overlayKey",
"subjects",
"status",
"trigger",
"attempts",
"correlationId",
"createdAt",
"startedAt",
"completedAt",
"error",
"metadata",
},
[typeof(PolicyRunRequest)] = new[]
{
"schemaVersion",
"tenantId",
"policyId",
"policyVersion",
"mode",
"priority",
"runId",
"queuedAt",
"requestedBy",
"correlationId",
"metadata",
"inputs",
},
[typeof(PolicyRunInputs)] = new[]
{
"sbomSet",
"advisoryCursor",
"vexCursor",
"environment",
"captureExplain",
},
[typeof(PolicyRunStatus)] = new[]
{
"schemaVersion",
"runId",
"tenantId",
"policyId",
"policyVersion",
"mode",
"status",
"priority",
"queuedAt",
"startedAt",
"finishedAt",
"determinismHash",
"errorCode",
"error",
"attempts",
"traceId",
"explainUri",
"metadata",
"stats",
"inputs",
},
[typeof(PolicyRunJob)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"policyId",
"policyVersion",
"mode",
"priority",
"priorityRank",
"runId",
"queuedAt",
"requestedBy",
"correlationId",
"metadata",
"inputs",
"status",
"attemptCount",
"lastAttemptAt",
"lastError",
"createdAt",
"updatedAt",
"availableAt",
"submittedAt",
"completedAt",
"leaseOwner",
"leaseExpiresAt",
"cancellationRequested",
"cancellationRequestedAt",
"cancellationReason",
"cancelledAt",
},
[typeof(PolicyRunStats)] = new[]
{
"components",
"rulesFired",
"findingsWritten",
"vexOverrides",
"quieted",
"suppressed",
"durationSeconds",
},
[typeof(PolicyDiffSummary)] = new[]
{
"schemaVersion",
"added",
"removed",
"unchanged",
"bySeverity",
"ruleHits",
},
[typeof(PolicyDiffSeverityDelta)] = new[]
{
"up",
"down",
},
[typeof(PolicyDiffRuleDelta)] = new[]
{
"ruleId",
"ruleName",
"up",
"down",
},
[typeof(PolicyExplainTrace)] = new[]
{
"schemaVersion",
"findingId",
"policyId",
"policyVersion",
"tenantId",
"runId",
"evaluatedAt",
"verdict",
"ruleChain",
"evidence",
"vexImpacts",
"history",
"metadata",
},
[typeof(PolicyExplainVerdict)] = new[]
{
"status",
"severity",
"quiet",
"score",
"rationale",
},
[typeof(PolicyExplainRule)] = new[]
{
"ruleId",
"ruleName",
"action",
"decision",
"score",
"condition",
},
[typeof(PolicyExplainEvidence)] = new[]
{
"type",
"reference",
"source",
"status",
"weight",
"justification",
"metadata",
},
[typeof(PolicyExplainVexImpact)] = new[]
{
"statementId",
"provider",
"status",
"accepted",
"justification",
"confidence",
},
[typeof(PolicyExplainHistoryEvent)] = new[]
{
"status",
"occurredAt",
"actor",
"note",
},
};
public static string Serialize<T>(T value)
=> JsonSerializer.Serialize(value, CompactOptions);
public static string SerializeIndented<T>(T value)
=> JsonSerializer.Serialize(value, PrettyOptions);
public static T Deserialize<T>(string json)
=> JsonSerializer.Deserialize<T>(json, PrettyOptions)
?? throw new InvalidOperationException($"Unable to deserialize {typeof(T).Name}.");
private static JsonSerializerOptions CreateOptions(bool writeIndented)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = writeIndented,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
var resolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
options.TypeInfoResolver = new DeterministicResolver(resolver);
options.Converters.Add(new ScheduleModeConverter());
options.Converters.Add(new SelectorScopeConverter());
options.Converters.Add(new RunTriggerConverter());
options.Converters.Add(new RunStateConverter());
options.Converters.Add(new SeverityRankConverter());
options.Converters.Add(new GraphJobStatusConverter());
options.Converters.Add(new GraphBuildJobTriggerConverter());
options.Converters.Add(new GraphOverlayJobTriggerConverter());
options.Converters.Add(new GraphOverlayKindConverter());
options.Converters.Add(new PolicyRunModeConverter());
options.Converters.Add(new PolicyRunPriorityConverter());
options.Converters.Add(new PolicyRunExecutionStatusConverter());
options.Converters.Add(new PolicyVerdictStatusConverter());
options.Converters.Add(new PolicyRunJobStatusConverter());
return options;
}
private sealed class DeterministicResolver : IJsonTypeInfoResolver
{
private readonly IJsonTypeInfoResolver _inner;
public DeterministicResolver(IJsonTypeInfoResolver inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
var info = _inner.GetTypeInfo(type, options);
if (info is null)
{
throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
}
if (info.Kind is JsonTypeInfoKind.Object && info.Properties.Count > 1)
{
var ordered = info.Properties
.OrderBy(property => ResolveOrder(type, property.Name))
.ThenBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
info.Properties.Clear();
foreach (var property in ordered)
{
info.Properties.Add(property);
}
}
return info;
}
private static int ResolveOrder(Type type, string propertyName)
{
if (PropertyOrder.TryGetValue(type, out var order))
{
var index = Array.IndexOf(order, propertyName);
if (index >= 0)
{
return index;
}
}
return int.MaxValue;
}
}
}

View File

@@ -0,0 +1,201 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
internal sealed class ScheduleModeConverter : HyphenatedEnumConverter<ScheduleMode>
{
protected override IReadOnlyDictionary<ScheduleMode, string> Map { get; } = new Dictionary<ScheduleMode, string>
{
[ScheduleMode.AnalysisOnly] = "analysis-only",
[ScheduleMode.ContentRefresh] = "content-refresh",
};
}
internal sealed class SelectorScopeConverter : HyphenatedEnumConverter<SelectorScope>
{
protected override IReadOnlyDictionary<SelectorScope, string> Map { get; } = new Dictionary<SelectorScope, string>
{
[SelectorScope.AllImages] = "all-images",
[SelectorScope.ByNamespace] = "by-namespace",
[SelectorScope.ByRepository] = "by-repo",
[SelectorScope.ByDigest] = "by-digest",
[SelectorScope.ByLabels] = "by-labels",
};
}
internal sealed class RunTriggerConverter : LowerCaseEnumConverter<RunTrigger>
{
}
internal sealed class RunStateConverter : LowerCaseEnumConverter<RunState>
{
}
internal sealed class SeverityRankConverter : LowerCaseEnumConverter<SeverityRank>
{
protected override string ConvertToString(SeverityRank value)
=> value switch
{
SeverityRank.None => "none",
SeverityRank.Info => "info",
SeverityRank.Low => "low",
SeverityRank.Medium => "medium",
SeverityRank.High => "high",
SeverityRank.Critical => "critical",
SeverityRank.Unknown => "unknown",
_ => throw new ArgumentOutOfRangeException(nameof(value), value, null),
};
}
internal sealed class GraphJobStatusConverter : LowerCaseEnumConverter<GraphJobStatus>
{
}
internal sealed class GraphBuildJobTriggerConverter : HyphenatedEnumConverter<GraphBuildJobTrigger>
{
protected override IReadOnlyDictionary<GraphBuildJobTrigger, string> Map { get; } = new Dictionary<GraphBuildJobTrigger, string>
{
[GraphBuildJobTrigger.SbomVersion] = "sbom-version",
[GraphBuildJobTrigger.Backfill] = "backfill",
[GraphBuildJobTrigger.Manual] = "manual",
};
}
internal sealed class GraphOverlayJobTriggerConverter : HyphenatedEnumConverter<GraphOverlayJobTrigger>
{
protected override IReadOnlyDictionary<GraphOverlayJobTrigger, string> Map { get; } = new Dictionary<GraphOverlayJobTrigger, string>
{
[GraphOverlayJobTrigger.Policy] = "policy",
[GraphOverlayJobTrigger.Advisory] = "advisory",
[GraphOverlayJobTrigger.Vex] = "vex",
[GraphOverlayJobTrigger.SbomVersion] = "sbom-version",
[GraphOverlayJobTrigger.Manual] = "manual",
};
}
internal sealed class GraphOverlayKindConverter : LowerCaseEnumConverter<GraphOverlayKind>
{
}
internal sealed class PolicyRunModeConverter : LowerCaseEnumConverter<PolicyRunMode>
{
}
internal sealed class PolicyRunPriorityConverter : LowerCaseEnumConverter<PolicyRunPriority>
{
}
internal sealed class PolicyRunExecutionStatusConverter : JsonConverter<PolicyRunExecutionStatus>
{
private static readonly IReadOnlyDictionary<string, PolicyRunExecutionStatus> Reverse = new Dictionary<string, PolicyRunExecutionStatus>(StringComparer.OrdinalIgnoreCase)
{
["queued"] = PolicyRunExecutionStatus.Queued,
["running"] = PolicyRunExecutionStatus.Running,
["succeeded"] = PolicyRunExecutionStatus.Succeeded,
["failed"] = PolicyRunExecutionStatus.Failed,
["canceled"] = PolicyRunExecutionStatus.Cancelled,
["cancelled"] = PolicyRunExecutionStatus.Cancelled,
["replay_pending"] = PolicyRunExecutionStatus.ReplayPending,
["replay-pending"] = PolicyRunExecutionStatus.ReplayPending,
};
private static readonly IReadOnlyDictionary<PolicyRunExecutionStatus, string> Forward = new Dictionary<PolicyRunExecutionStatus, string>
{
[PolicyRunExecutionStatus.Queued] = "queued",
[PolicyRunExecutionStatus.Running] = "running",
[PolicyRunExecutionStatus.Succeeded] = "succeeded",
[PolicyRunExecutionStatus.Failed] = "failed",
[PolicyRunExecutionStatus.Cancelled] = "canceled",
[PolicyRunExecutionStatus.ReplayPending] = "replay_pending",
};
public override PolicyRunExecutionStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
if (value is not null && Reverse.TryGetValue(value, out var status))
{
return status;
}
throw new JsonException($"Value '{value}' is not a valid {nameof(PolicyRunExecutionStatus)}.");
}
public override void Write(Utf8JsonWriter writer, PolicyRunExecutionStatus value, JsonSerializerOptions options)
{
if (!Forward.TryGetValue(value, out var text))
{
throw new JsonException($"Unable to serialize {nameof(PolicyRunExecutionStatus)} value '{value}'.");
}
writer.WriteStringValue(text);
}
}
internal sealed class PolicyVerdictStatusConverter : LowerCaseEnumConverter<PolicyVerdictStatus>
{
}
internal sealed class PolicyRunJobStatusConverter : LowerCaseEnumConverter<PolicyRunJobStatus>
{
}
internal abstract class HyphenatedEnumConverter<TEnum> : JsonConverter<TEnum>
where TEnum : struct, Enum
{
private readonly Dictionary<string, TEnum> _reverse;
protected HyphenatedEnumConverter()
{
_reverse = Map.ToDictionary(static pair => pair.Value, static pair => pair.Key, StringComparer.OrdinalIgnoreCase);
}
protected abstract IReadOnlyDictionary<TEnum, string> Map { get; }
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
if (value is not null && _reverse.TryGetValue(value, out var parsed))
{
return parsed;
}
throw new JsonException($"Value '{value}' is not a valid {typeof(TEnum).Name}.");
}
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
{
if (Map.TryGetValue(value, out var text))
{
writer.WriteStringValue(text);
return;
}
throw new JsonException($"Unable to serialize {typeof(TEnum).Name} value '{value}'.");
}
}
internal class LowerCaseEnumConverter<TEnum> : JsonConverter<TEnum>
where TEnum : struct, Enum
{
private static readonly Dictionary<string, TEnum> Reverse = Enum
.GetValues<TEnum>()
.ToDictionary(static value => value.ToString().ToLowerInvariant(), static value => value, StringComparer.OrdinalIgnoreCase);
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
if (value is not null && Reverse.TryGetValue(value, out var parsed))
{
return parsed;
}
throw new JsonException($"Value '{value}' is not a valid {typeof(TEnum).Name}.");
}
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
=> writer.WriteStringValue(ConvertToString(value));
protected virtual string ConvertToString(TEnum value)
=> value.ToString().ToLowerInvariant();
}

View File

@@ -0,0 +1,179 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Execution mode for a schedule.
/// </summary>
[JsonConverter(typeof(ScheduleModeConverter))]
public enum ScheduleMode
{
AnalysisOnly,
ContentRefresh,
}
/// <summary>
/// Selector scope determining which filters are applied.
/// </summary>
[JsonConverter(typeof(SelectorScopeConverter))]
public enum SelectorScope
{
AllImages,
ByNamespace,
ByRepository,
ByDigest,
ByLabels,
}
/// <summary>
/// Source that triggered a run.
/// </summary>
[JsonConverter(typeof(RunTriggerConverter))]
public enum RunTrigger
{
Cron,
Feedser,
Vexer,
Manual,
}
/// <summary>
/// Lifecycle state of a scheduler run.
/// </summary>
[JsonConverter(typeof(RunStateConverter))]
public enum RunState
{
Planning,
Queued,
Running,
Completed,
Error,
Cancelled,
}
/// <summary>
/// Severity rankings used in scheduler payloads.
/// </summary>
[JsonConverter(typeof(SeverityRankConverter))]
public enum SeverityRank
{
None = 0,
Info = 1,
Low = 2,
Medium = 3,
High = 4,
Critical = 5,
Unknown = 6,
}
/// <summary>
/// Status lifecycle shared by graph build and overlay jobs.
/// </summary>
[JsonConverter(typeof(GraphJobStatusConverter))]
public enum GraphJobStatus
{
Pending,
Queued,
Running,
Completed,
Failed,
Cancelled,
}
/// <summary>
/// Trigger indicating why a graph build job was enqueued.
/// </summary>
[JsonConverter(typeof(GraphBuildJobTriggerConverter))]
public enum GraphBuildJobTrigger
{
SbomVersion,
Backfill,
Manual,
}
/// <summary>
/// Trigger indicating why a graph overlay job was enqueued.
/// </summary>
[JsonConverter(typeof(GraphOverlayJobTriggerConverter))]
public enum GraphOverlayJobTrigger
{
Policy,
Advisory,
Vex,
SbomVersion,
Manual,
}
/// <summary>
/// Overlay category applied to a graph snapshot.
/// </summary>
[JsonConverter(typeof(GraphOverlayKindConverter))]
public enum GraphOverlayKind
{
Policy,
Advisory,
Vex,
}
/// <summary>
/// Mode for policy runs executed by the Policy Engine.
/// </summary>
[JsonConverter(typeof(PolicyRunModeConverter))]
public enum PolicyRunMode
{
Full,
Incremental,
Simulate,
}
/// <summary>
/// Priority assigned to a policy run request.
/// </summary>
[JsonConverter(typeof(PolicyRunPriorityConverter))]
public enum PolicyRunPriority
{
Normal,
High,
Emergency,
}
/// <summary>
/// Execution status for policy runs tracked in policy_runs.
/// </summary>
[JsonConverter(typeof(PolicyRunExecutionStatusConverter))]
public enum PolicyRunExecutionStatus
{
Queued,
Running,
Succeeded,
Failed,
Cancelled,
ReplayPending,
}
/// <summary>
/// Resulting verdict for a policy evaluation.
/// </summary>
[JsonConverter(typeof(PolicyVerdictStatusConverter))]
public enum PolicyVerdictStatus
{
Passed,
Warned,
Blocked,
Quieted,
Ignored,
}
/// <summary>
/// Lifecycle status for scheduler policy run jobs.
/// </summary>
[JsonConverter(typeof(PolicyRunJobStatusConverter))]
public enum PolicyRunJobStatus
{
Pending,
Dispatching,
Submitted,
Completed,
Failed,
Cancelled,
}

View File

@@ -0,0 +1,132 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Job instructing Cartographer to materialize a graph snapshot for an SBOM version.
/// </summary>
public sealed record GraphBuildJob
{
public GraphBuildJob(
string id,
string tenantId,
string sbomId,
string sbomVersionId,
string sbomDigest,
GraphJobStatus status,
GraphBuildJobTrigger trigger,
DateTimeOffset createdAt,
string? graphSnapshotId = null,
int attempts = 0,
string? cartographerJobId = null,
string? correlationId = null,
DateTimeOffset? startedAt = null,
DateTimeOffset? completedAt = null,
string? error = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
string? schemaVersion = null)
: this(
id,
tenantId,
sbomId,
sbomVersionId,
sbomDigest,
Validation.TrimToNull(graphSnapshotId),
status,
trigger,
Validation.EnsureNonNegative(attempts, nameof(attempts)),
Validation.TrimToNull(cartographerJobId),
Validation.TrimToNull(correlationId),
Validation.NormalizeTimestamp(createdAt),
Validation.NormalizeTimestamp(startedAt),
Validation.NormalizeTimestamp(completedAt),
Validation.TrimToNull(error),
Validation.NormalizeMetadata(metadata),
schemaVersion)
{
}
[JsonConstructor]
public GraphBuildJob(
string id,
string tenantId,
string sbomId,
string sbomVersionId,
string sbomDigest,
string? graphSnapshotId,
GraphJobStatus status,
GraphBuildJobTrigger trigger,
int attempts,
string? cartographerJobId,
string? correlationId,
DateTimeOffset createdAt,
DateTimeOffset? startedAt,
DateTimeOffset? completedAt,
string? error,
ImmutableSortedDictionary<string, string> metadata,
string? schemaVersion = null)
{
Id = Validation.EnsureId(id, nameof(id));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
SbomId = Validation.EnsureId(sbomId, nameof(sbomId));
SbomVersionId = Validation.EnsureId(sbomVersionId, nameof(sbomVersionId));
SbomDigest = Validation.EnsureDigestFormat(sbomDigest, nameof(sbomDigest));
GraphSnapshotId = Validation.TrimToNull(graphSnapshotId);
Status = status;
Trigger = trigger;
Attempts = Validation.EnsureNonNegative(attempts, nameof(attempts));
CartographerJobId = Validation.TrimToNull(cartographerJobId);
CorrelationId = Validation.TrimToNull(correlationId);
CreatedAt = Validation.NormalizeTimestamp(createdAt);
StartedAt = Validation.NormalizeTimestamp(startedAt);
CompletedAt = Validation.NormalizeTimestamp(completedAt);
Error = Validation.TrimToNull(error);
var materializedMetadata = metadata ?? ImmutableSortedDictionary<string, string>.Empty;
Metadata = materializedMetadata.Count > 0
? materializedMetadata.WithComparers(StringComparer.Ordinal)
: ImmutableSortedDictionary<string, string>.Empty;
SchemaVersion = SchedulerSchemaVersions.EnsureGraphBuildJob(schemaVersion);
}
public string SchemaVersion { get; }
public string Id { get; }
public string TenantId { get; }
public string SbomId { get; }
public string SbomVersionId { get; }
public string SbomDigest { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? GraphSnapshotId { get; init; }
public GraphJobStatus Status { get; init; }
public GraphBuildJobTrigger Trigger { get; }
public int Attempts { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CartographerJobId { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CorrelationId { get; init; }
public DateTimeOffset CreatedAt { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? StartedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CompletedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
}

View File

@@ -0,0 +1,241 @@
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Encapsulates allowed status transitions and invariants for graph jobs.
/// </summary>
public static class GraphJobStateMachine
{
private static readonly IReadOnlyDictionary<GraphJobStatus, GraphJobStatus[]> Adjacency = new Dictionary<GraphJobStatus, GraphJobStatus[]>
{
[GraphJobStatus.Pending] = new[] { GraphJobStatus.Pending, GraphJobStatus.Queued, GraphJobStatus.Running, GraphJobStatus.Failed, GraphJobStatus.Cancelled },
[GraphJobStatus.Queued] = new[] { GraphJobStatus.Queued, GraphJobStatus.Running, GraphJobStatus.Failed, GraphJobStatus.Cancelled },
[GraphJobStatus.Running] = new[] { GraphJobStatus.Running, GraphJobStatus.Completed, GraphJobStatus.Failed, GraphJobStatus.Cancelled },
[GraphJobStatus.Completed] = new[] { GraphJobStatus.Completed },
[GraphJobStatus.Failed] = new[] { GraphJobStatus.Failed },
[GraphJobStatus.Cancelled] = new[] { GraphJobStatus.Cancelled },
};
public static bool CanTransition(GraphJobStatus from, GraphJobStatus to)
{
if (!Adjacency.TryGetValue(from, out var allowed))
{
return false;
}
return allowed.Contains(to);
}
public static bool IsTerminal(GraphJobStatus status)
=> status is GraphJobStatus.Completed or GraphJobStatus.Failed or GraphJobStatus.Cancelled;
public static GraphBuildJob EnsureTransition(
GraphBuildJob job,
GraphJobStatus next,
DateTimeOffset timestamp,
int? attempts = null,
string? errorMessage = null)
{
ArgumentNullException.ThrowIfNull(job);
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
var current = job.Status;
if (!CanTransition(current, next))
{
throw new InvalidOperationException($"Graph build job transition from '{current}' to '{next}' is not allowed.");
}
var nextAttempts = attempts ?? job.Attempts;
if (nextAttempts < job.Attempts)
{
throw new InvalidOperationException("Graph job attempts cannot decrease.");
}
var startedAt = job.StartedAt;
var completedAt = job.CompletedAt;
if (current != GraphJobStatus.Running && next == GraphJobStatus.Running && startedAt is null)
{
startedAt = normalizedTimestamp;
}
if (IsTerminal(next))
{
completedAt ??= normalizedTimestamp;
}
string? nextError = null;
if (next == GraphJobStatus.Failed)
{
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? job.Error : errorMessage.Trim();
if (string.IsNullOrWhiteSpace(effectiveError))
{
throw new InvalidOperationException("Transitioning to Failed requires a non-empty error message.");
}
nextError = effectiveError;
}
else if (!string.IsNullOrWhiteSpace(errorMessage))
{
throw new InvalidOperationException("Error message can only be provided when transitioning to Failed state.");
}
var updated = job with
{
Status = next,
Attempts = nextAttempts,
StartedAt = startedAt,
CompletedAt = completedAt,
Error = nextError,
};
Validate(updated);
return updated;
}
public static GraphOverlayJob EnsureTransition(
GraphOverlayJob job,
GraphJobStatus next,
DateTimeOffset timestamp,
int? attempts = null,
string? errorMessage = null)
{
ArgumentNullException.ThrowIfNull(job);
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
var current = job.Status;
if (!CanTransition(current, next))
{
throw new InvalidOperationException($"Graph overlay job transition from '{current}' to '{next}' is not allowed.");
}
var nextAttempts = attempts ?? job.Attempts;
if (nextAttempts < job.Attempts)
{
throw new InvalidOperationException("Graph job attempts cannot decrease.");
}
var startedAt = job.StartedAt;
var completedAt = job.CompletedAt;
if (current != GraphJobStatus.Running && next == GraphJobStatus.Running && startedAt is null)
{
startedAt = normalizedTimestamp;
}
if (IsTerminal(next))
{
completedAt ??= normalizedTimestamp;
}
string? nextError = null;
if (next == GraphJobStatus.Failed)
{
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? job.Error : errorMessage.Trim();
if (string.IsNullOrWhiteSpace(effectiveError))
{
throw new InvalidOperationException("Transitioning to Failed requires a non-empty error message.");
}
nextError = effectiveError;
}
else if (!string.IsNullOrWhiteSpace(errorMessage))
{
throw new InvalidOperationException("Error message can only be provided when transitioning to Failed state.");
}
var updated = job with
{
Status = next,
Attempts = nextAttempts,
StartedAt = startedAt,
CompletedAt = completedAt,
Error = nextError,
};
Validate(updated);
return updated;
}
public static void Validate(GraphBuildJob job)
{
ArgumentNullException.ThrowIfNull(job);
if (job.StartedAt is { } started && started < job.CreatedAt)
{
throw new InvalidOperationException("GraphBuildJob.StartedAt cannot be earlier than CreatedAt.");
}
if (job.CompletedAt is { } completed)
{
if (job.StartedAt is { } start && completed < start)
{
throw new InvalidOperationException("GraphBuildJob.CompletedAt cannot be earlier than StartedAt.");
}
if (!IsTerminal(job.Status))
{
throw new InvalidOperationException("GraphBuildJob.CompletedAt set while status is not terminal.");
}
}
else if (IsTerminal(job.Status))
{
throw new InvalidOperationException("Terminal graph build job states must include CompletedAt.");
}
if (job.Status == GraphJobStatus.Failed)
{
if (string.IsNullOrWhiteSpace(job.Error))
{
throw new InvalidOperationException("GraphBuildJob.Error must be populated when status is Failed.");
}
}
else if (!string.IsNullOrWhiteSpace(job.Error))
{
throw new InvalidOperationException("GraphBuildJob.Error must be null for non-failed states.");
}
}
public static void Validate(GraphOverlayJob job)
{
ArgumentNullException.ThrowIfNull(job);
if (job.StartedAt is { } started && started < job.CreatedAt)
{
throw new InvalidOperationException("GraphOverlayJob.StartedAt cannot be earlier than CreatedAt.");
}
if (job.CompletedAt is { } completed)
{
if (job.StartedAt is { } start && completed < start)
{
throw new InvalidOperationException("GraphOverlayJob.CompletedAt cannot be earlier than StartedAt.");
}
if (!IsTerminal(job.Status))
{
throw new InvalidOperationException("GraphOverlayJob.CompletedAt set while status is not terminal.");
}
}
else if (IsTerminal(job.Status))
{
throw new InvalidOperationException("Terminal graph overlay job states must include CompletedAt.");
}
if (job.Status == GraphJobStatus.Failed)
{
if (string.IsNullOrWhiteSpace(job.Error))
{
throw new InvalidOperationException("GraphOverlayJob.Error must be populated when status is Failed.");
}
}
else if (!string.IsNullOrWhiteSpace(job.Error))
{
throw new InvalidOperationException("GraphOverlayJob.Error must be null for non-failed states.");
}
}
}

View File

@@ -0,0 +1,132 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Job that materializes or refreshes an overlay on top of an existing graph snapshot.
/// </summary>
public sealed record GraphOverlayJob
{
public GraphOverlayJob(
string id,
string tenantId,
string graphSnapshotId,
GraphOverlayKind overlayKind,
string overlayKey,
GraphJobStatus status,
GraphOverlayJobTrigger trigger,
DateTimeOffset createdAt,
IEnumerable<string>? subjects = null,
int attempts = 0,
string? buildJobId = null,
string? correlationId = null,
DateTimeOffset? startedAt = null,
DateTimeOffset? completedAt = null,
string? error = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
string? schemaVersion = null)
: this(
id,
tenantId,
graphSnapshotId,
Validation.TrimToNull(buildJobId),
overlayKind,
Validation.EnsureNotNullOrWhiteSpace(overlayKey, nameof(overlayKey)),
Validation.NormalizeStringSet(subjects, nameof(subjects)),
status,
trigger,
Validation.EnsureNonNegative(attempts, nameof(attempts)),
Validation.TrimToNull(correlationId),
Validation.NormalizeTimestamp(createdAt),
Validation.NormalizeTimestamp(startedAt),
Validation.NormalizeTimestamp(completedAt),
Validation.TrimToNull(error),
Validation.NormalizeMetadata(metadata),
schemaVersion)
{
}
[JsonConstructor]
public GraphOverlayJob(
string id,
string tenantId,
string graphSnapshotId,
string? buildJobId,
GraphOverlayKind overlayKind,
string overlayKey,
ImmutableArray<string> subjects,
GraphJobStatus status,
GraphOverlayJobTrigger trigger,
int attempts,
string? correlationId,
DateTimeOffset createdAt,
DateTimeOffset? startedAt,
DateTimeOffset? completedAt,
string? error,
ImmutableSortedDictionary<string, string> metadata,
string? schemaVersion = null)
{
Id = Validation.EnsureId(id, nameof(id));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
GraphSnapshotId = Validation.EnsureId(graphSnapshotId, nameof(graphSnapshotId));
BuildJobId = Validation.TrimToNull(buildJobId);
OverlayKind = overlayKind;
OverlayKey = Validation.EnsureNotNullOrWhiteSpace(overlayKey, nameof(overlayKey));
Subjects = subjects.IsDefault ? ImmutableArray<string>.Empty : subjects;
Status = status;
Trigger = trigger;
Attempts = Validation.EnsureNonNegative(attempts, nameof(attempts));
CorrelationId = Validation.TrimToNull(correlationId);
CreatedAt = Validation.NormalizeTimestamp(createdAt);
StartedAt = Validation.NormalizeTimestamp(startedAt);
CompletedAt = Validation.NormalizeTimestamp(completedAt);
Error = Validation.TrimToNull(error);
var materializedMetadata = metadata ?? ImmutableSortedDictionary<string, string>.Empty;
Metadata = materializedMetadata.Count > 0
? materializedMetadata.WithComparers(StringComparer.Ordinal)
: ImmutableSortedDictionary<string, string>.Empty;
SchemaVersion = SchedulerSchemaVersions.EnsureGraphOverlayJob(schemaVersion);
}
public string SchemaVersion { get; }
public string Id { get; }
public string TenantId { get; }
public string GraphSnapshotId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? BuildJobId { get; init; }
public GraphOverlayKind OverlayKind { get; }
public string OverlayKey { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Subjects { get; } = ImmutableArray<string>.Empty;
public GraphJobStatus Status { get; init; }
public GraphOverlayJobTrigger Trigger { get; }
public int Attempts { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CorrelationId { get; init; }
public DateTimeOffset CreatedAt { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? StartedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CompletedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
}

View File

@@ -0,0 +1,138 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Result from resolving impacted images for a selector.
/// </summary>
public sealed record ImpactSet
{
public ImpactSet(
Selector selector,
IEnumerable<ImpactImage> images,
bool usageOnly,
DateTimeOffset generatedAt,
int? total = null,
string? snapshotId = null,
string? schemaVersion = null)
: this(
selector,
NormalizeImages(images),
usageOnly,
Validation.NormalizeTimestamp(generatedAt),
total ?? images.Count(),
Validation.TrimToNull(snapshotId),
schemaVersion)
{
}
[JsonConstructor]
public ImpactSet(
Selector selector,
ImmutableArray<ImpactImage> images,
bool usageOnly,
DateTimeOffset generatedAt,
int total,
string? snapshotId,
string? schemaVersion = null)
{
Selector = selector ?? throw new ArgumentNullException(nameof(selector));
Images = images.IsDefault ? ImmutableArray<ImpactImage>.Empty : images;
UsageOnly = usageOnly;
GeneratedAt = Validation.NormalizeTimestamp(generatedAt);
Total = Validation.EnsureNonNegative(total, nameof(total));
SnapshotId = Validation.TrimToNull(snapshotId);
SchemaVersion = SchedulerSchemaVersions.EnsureImpactSet(schemaVersion);
}
public string SchemaVersion { get; }
public Selector Selector { get; }
public ImmutableArray<ImpactImage> Images { get; } = ImmutableArray<ImpactImage>.Empty;
public bool UsageOnly { get; }
public DateTimeOffset GeneratedAt { get; }
public int Total { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SnapshotId { get; }
private static ImmutableArray<ImpactImage> NormalizeImages(IEnumerable<ImpactImage> images)
{
ArgumentNullException.ThrowIfNull(images);
return images
.Where(static image => image is not null)
.Select(static image => image!)
.OrderBy(static image => image.ImageDigest, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
/// Impacted image descriptor returned from the impact index.
/// </summary>
public sealed record ImpactImage
{
public ImpactImage(
string imageDigest,
string registry,
string repository,
IEnumerable<string>? namespaces = null,
IEnumerable<string>? tags = null,
bool usedByEntrypoint = false,
IEnumerable<KeyValuePair<string, string>>? labels = null)
: this(
Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest)),
Validation.EnsureSimpleIdentifier(registry, nameof(registry)),
Validation.EnsureSimpleIdentifier(repository, nameof(repository)),
Validation.NormalizeStringSet(namespaces, nameof(namespaces)),
Validation.NormalizeTagPatterns(tags),
usedByEntrypoint,
Validation.NormalizeMetadata(labels))
{
}
[JsonConstructor]
public ImpactImage(
string imageDigest,
string registry,
string repository,
ImmutableArray<string> namespaces,
ImmutableArray<string> tags,
bool usedByEntrypoint,
ImmutableSortedDictionary<string, string> labels)
{
ImageDigest = Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest));
Registry = Validation.EnsureSimpleIdentifier(registry, nameof(registry));
Repository = Validation.EnsureSimpleIdentifier(repository, nameof(repository));
Namespaces = namespaces.IsDefault ? ImmutableArray<string>.Empty : namespaces;
Tags = tags.IsDefault ? ImmutableArray<string>.Empty : tags;
UsedByEntrypoint = usedByEntrypoint;
var materializedLabels = labels ?? ImmutableSortedDictionary<string, string>.Empty;
Labels = materializedLabels.Count > 0
? materializedLabels.WithComparers(StringComparer.Ordinal)
: ImmutableSortedDictionary<string, string>.Empty;
}
public string ImageDigest { get; }
public string Registry { get; }
public string Repository { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Namespaces { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Tags { get; } = ImmutableArray<string>.Empty;
public bool UsedByEntrypoint { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Labels { get; } = ImmutableSortedDictionary<string, string>.Empty;
}

View File

@@ -0,0 +1,185 @@
using System;
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
public sealed record PolicyRunJob(
string SchemaVersion,
string Id,
string TenantId,
string PolicyId,
int? PolicyVersion,
PolicyRunMode Mode,
PolicyRunPriority Priority,
int PriorityRank,
string? RunId,
string? RequestedBy,
string? CorrelationId,
ImmutableSortedDictionary<string, string>? Metadata,
PolicyRunInputs Inputs,
DateTimeOffset? QueuedAt,
PolicyRunJobStatus Status,
int AttemptCount,
DateTimeOffset? LastAttemptAt,
string? LastError,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset AvailableAt,
DateTimeOffset? SubmittedAt,
DateTimeOffset? CompletedAt,
string? LeaseOwner,
DateTimeOffset? LeaseExpiresAt,
bool CancellationRequested,
DateTimeOffset? CancellationRequestedAt,
string? CancellationReason,
DateTimeOffset? CancelledAt)
{
public string SchemaVersion { get; init; } = SchedulerSchemaVersions.EnsurePolicyRunJob(SchemaVersion);
public string Id { get; init; } = Validation.EnsureId(Id, nameof(Id));
public string TenantId { get; init; } = Validation.EnsureTenantId(TenantId, nameof(TenantId));
public string PolicyId { get; init; } = Validation.EnsureSimpleIdentifier(PolicyId, nameof(PolicyId));
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? PolicyVersion { get; init; } = EnsurePolicyVersion(PolicyVersion);
public PolicyRunMode Mode { get; init; } = Mode;
public PolicyRunPriority Priority { get; init; } = Priority;
public int PriorityRank { get; init; } = PriorityRank >= 0 ? PriorityRank : GetPriorityRank(Priority);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RunId { get; init; } = NormalizeRunId(RunId);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RequestedBy { get; init; } = Validation.TrimToNull(RequestedBy);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CorrelationId { get; init; } = Validation.TrimToNull(CorrelationId);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableSortedDictionary<string, string>? Metadata { get; init; } = NormalizeMetadata(Metadata);
public PolicyRunInputs Inputs { get; init; } = Inputs ?? throw new ArgumentNullException(nameof(Inputs));
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? QueuedAt { get; init; } = Validation.NormalizeTimestamp(QueuedAt);
public PolicyRunJobStatus Status { get; init; } = Status;
public int AttemptCount { get; init; } = Validation.EnsureNonNegative(AttemptCount, nameof(AttemptCount));
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? LastAttemptAt { get; init; } = Validation.NormalizeTimestamp(LastAttemptAt);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? LastError { get; init; } = Validation.TrimToNull(LastError);
public DateTimeOffset CreatedAt { get; init; } = NormalizeTimestamp(CreatedAt, nameof(CreatedAt));
public DateTimeOffset UpdatedAt { get; init; } = NormalizeTimestamp(UpdatedAt, nameof(UpdatedAt));
public DateTimeOffset AvailableAt { get; init; } = NormalizeTimestamp(AvailableAt, nameof(AvailableAt));
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? SubmittedAt { get; init; } = Validation.NormalizeTimestamp(SubmittedAt);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CompletedAt { get; init; } = Validation.NormalizeTimestamp(CompletedAt);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? LeaseOwner { get; init; } = Validation.TrimToNull(LeaseOwner);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? LeaseExpiresAt { get; init; } = Validation.NormalizeTimestamp(LeaseExpiresAt);
public bool CancellationRequested { get; init; } = CancellationRequested;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CancellationRequestedAt { get; init; } = Validation.NormalizeTimestamp(CancellationRequestedAt);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CancellationReason { get; init; } = Validation.TrimToNull(CancellationReason);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CancelledAt { get; init; } = Validation.NormalizeTimestamp(CancelledAt);
public PolicyRunRequest ToPolicyRunRequest(DateTimeOffset fallbackQueuedAt)
{
var queuedAt = QueuedAt ?? fallbackQueuedAt;
return new PolicyRunRequest(
TenantId,
PolicyId,
Mode,
Inputs,
Priority,
RunId,
PolicyVersion,
RequestedBy,
queuedAt,
CorrelationId,
Metadata);
}
private static int? EnsurePolicyVersion(int? value)
{
if (value is not null && value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(PolicyVersion), value, "Policy version must be positive.");
}
return value;
}
private static string? NormalizeRunId(string? runId)
{
var trimmed = Validation.TrimToNull(runId);
return trimmed is null ? null : Validation.EnsureId(trimmed, nameof(runId));
}
private static ImmutableSortedDictionary<string, string>? NormalizeMetadata(ImmutableSortedDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return null;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var (key, value) in metadata)
{
var normalizedKey = Validation.TrimToNull(key);
var normalizedValue = Validation.TrimToNull(value);
if (normalizedKey is null || normalizedValue is null)
{
continue;
}
builder[normalizedKey.ToLowerInvariant()] = normalizedValue;
}
return builder.Count == 0 ? null : builder.ToImmutable();
}
private static int GetPriorityRank(PolicyRunPriority priority)
=> priority switch
{
PolicyRunPriority.Emergency => 2,
PolicyRunPriority.High => 1,
_ => 0
};
private static DateTimeOffset NormalizeTimestamp(DateTimeOffset value, string propertyName)
{
var normalized = Validation.NormalizeTimestamp(value);
if (normalized == default)
{
throw new ArgumentException($"{propertyName} must be a valid timestamp.", propertyName);
}
return normalized;
}
}

View File

@@ -0,0 +1,930 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Request payload enqueued by the policy orchestrator/clients.
/// </summary>
public sealed record PolicyRunRequest
{
public PolicyRunRequest(
string tenantId,
string policyId,
PolicyRunMode mode,
PolicyRunInputs? inputs = null,
PolicyRunPriority priority = PolicyRunPriority.Normal,
string? runId = null,
int? policyVersion = null,
string? requestedBy = null,
DateTimeOffset? queuedAt = null,
string? correlationId = null,
ImmutableSortedDictionary<string, string>? metadata = null,
string? schemaVersion = null)
: this(
tenantId,
policyId,
policyVersion,
mode,
priority,
runId,
Validation.NormalizeTimestamp(queuedAt),
Validation.TrimToNull(requestedBy),
Validation.TrimToNull(correlationId),
metadata ?? ImmutableSortedDictionary<string, string>.Empty,
inputs ?? PolicyRunInputs.Empty,
schemaVersion)
{
}
[JsonConstructor]
public PolicyRunRequest(
string tenantId,
string policyId,
int? policyVersion,
PolicyRunMode mode,
PolicyRunPriority priority,
string? runId,
DateTimeOffset? queuedAt,
string? requestedBy,
string? correlationId,
ImmutableSortedDictionary<string, string> metadata,
PolicyRunInputs inputs,
string? schemaVersion = null)
{
SchemaVersion = SchedulerSchemaVersions.EnsurePolicyRunRequest(schemaVersion);
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
PolicyId = Validation.EnsureSimpleIdentifier(policyId, nameof(policyId));
if (policyVersion is not null && policyVersion <= 0)
{
throw new ArgumentOutOfRangeException(nameof(policyVersion), policyVersion, "Policy version must be positive.");
}
PolicyVersion = policyVersion;
Mode = mode;
Priority = priority;
RunId = Validation.TrimToNull(runId) is { Length: > 0 } normalizedRunId
? Validation.EnsureId(normalizedRunId, nameof(runId))
: null;
QueuedAt = Validation.NormalizeTimestamp(queuedAt);
RequestedBy = Validation.TrimToNull(requestedBy);
CorrelationId = Validation.TrimToNull(correlationId);
var normalizedMetadata = (metadata ?? ImmutableSortedDictionary<string, string>.Empty)
.Select(static pair => new KeyValuePair<string, string>(
Validation.TrimToNull(pair.Key)?.ToLowerInvariant() ?? string.Empty,
Validation.TrimToNull(pair.Value) ?? string.Empty))
.Where(static pair => !string.IsNullOrEmpty(pair.Key) && !string.IsNullOrEmpty(pair.Value))
.DistinctBy(static pair => pair.Key, StringComparer.Ordinal)
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
Metadata = normalizedMetadata.Count == 0 ? null : normalizedMetadata;
Inputs = inputs ?? PolicyRunInputs.Empty;
}
public string SchemaVersion { get; }
public string TenantId { get; }
public string PolicyId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? PolicyVersion { get; }
public PolicyRunMode Mode { get; }
public PolicyRunPriority Priority { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RunId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? QueuedAt { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RequestedBy { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CorrelationId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableSortedDictionary<string, string>? Metadata { get; }
public PolicyRunInputs Inputs { get; } = PolicyRunInputs.Empty;
}
/// <summary>
/// Scoped inputs for policy runs (SBOM set, cursors, environment).
/// </summary>
public sealed record PolicyRunInputs
{
public static PolicyRunInputs Empty { get; } = new();
public PolicyRunInputs(
IEnumerable<string>? sbomSet = null,
DateTimeOffset? advisoryCursor = null,
DateTimeOffset? vexCursor = null,
IEnumerable<KeyValuePair<string, object?>>? env = null,
bool captureExplain = false)
{
_sbomSet = NormalizeSbomSet(sbomSet);
_advisoryCursor = Validation.NormalizeTimestamp(advisoryCursor);
_vexCursor = Validation.NormalizeTimestamp(vexCursor);
_environment = NormalizeEnvironment(env);
CaptureExplain = captureExplain;
}
public PolicyRunInputs()
{
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> SbomSet
{
get => _sbomSet;
init => _sbomSet = NormalizeSbomSet(value);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? AdvisoryCursor
{
get => _advisoryCursor;
init => _advisoryCursor = Validation.NormalizeTimestamp(value);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? VexCursor
{
get => _vexCursor;
init => _vexCursor = Validation.NormalizeTimestamp(value);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public IReadOnlyDictionary<string, JsonElement> Environment
{
get => _environment;
init => _environment = NormalizeEnvironment(value);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool CaptureExplain { get; init; }
private ImmutableArray<string> _sbomSet = ImmutableArray<string>.Empty;
private DateTimeOffset? _advisoryCursor;
private DateTimeOffset? _vexCursor;
private IReadOnlyDictionary<string, JsonElement> _environment = ImmutableSortedDictionary<string, JsonElement>.Empty;
private static ImmutableArray<string> NormalizeSbomSet(IEnumerable<string>? values)
=> Validation.NormalizeStringSet(values, nameof(SbomSet));
private static ImmutableArray<string> NormalizeSbomSet(ImmutableArray<string> values)
=> values.IsDefaultOrEmpty ? ImmutableArray<string>.Empty : NormalizeSbomSet(values.AsEnumerable());
private static IReadOnlyDictionary<string, JsonElement> NormalizeEnvironment(IEnumerable<KeyValuePair<string, object?>>? entries)
{
if (entries is null)
{
return ImmutableSortedDictionary<string, JsonElement>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, JsonElement>(StringComparer.Ordinal);
foreach (var entry in entries)
{
var key = Validation.TrimToNull(entry.Key);
if (key is null)
{
continue;
}
var normalizedKey = key.ToLowerInvariant();
var element = entry.Value switch
{
JsonElement jsonElement => jsonElement.Clone(),
JsonDocument jsonDocument => jsonDocument.RootElement.Clone(),
string text => JsonSerializer.SerializeToElement(text).Clone(),
bool boolean => JsonSerializer.SerializeToElement(boolean).Clone(),
int integer => JsonSerializer.SerializeToElement(integer).Clone(),
long longValue => JsonSerializer.SerializeToElement(longValue).Clone(),
double doubleValue => JsonSerializer.SerializeToElement(doubleValue).Clone(),
decimal decimalValue => JsonSerializer.SerializeToElement(decimalValue).Clone(),
null => JsonSerializer.SerializeToElement<object?>(null).Clone(),
_ => JsonSerializer.SerializeToElement(entry.Value, entry.Value.GetType()).Clone(),
};
builder[normalizedKey] = element;
}
return builder.ToImmutable();
}
private static IReadOnlyDictionary<string, JsonElement> NormalizeEnvironment(IReadOnlyDictionary<string, JsonElement>? environment)
{
if (environment is null || environment.Count == 0)
{
return ImmutableSortedDictionary<string, JsonElement>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, JsonElement>(StringComparer.Ordinal);
foreach (var entry in environment)
{
var key = Validation.TrimToNull(entry.Key);
if (key is null)
{
continue;
}
builder[key.ToLowerInvariant()] = entry.Value.Clone();
}
return builder.ToImmutable();
}
}
/// <summary>
/// Stored status for a policy run (policy_runs collection).
/// </summary>
public sealed record PolicyRunStatus
{
public PolicyRunStatus(
string runId,
string tenantId,
string policyId,
int policyVersion,
PolicyRunMode mode,
PolicyRunExecutionStatus status,
PolicyRunPriority priority,
DateTimeOffset queuedAt,
PolicyRunStats? stats = null,
PolicyRunInputs? inputs = null,
DateTimeOffset? startedAt = null,
DateTimeOffset? finishedAt = null,
string? determinismHash = null,
string? errorCode = null,
string? error = null,
int attempts = 0,
string? traceId = null,
string? explainUri = null,
ImmutableSortedDictionary<string, string>? metadata = null,
string? schemaVersion = null)
: this(
runId,
tenantId,
policyId,
policyVersion,
mode,
status,
priority,
Validation.NormalizeTimestamp(queuedAt),
Validation.NormalizeTimestamp(startedAt),
Validation.NormalizeTimestamp(finishedAt),
stats ?? PolicyRunStats.Empty,
inputs ?? PolicyRunInputs.Empty,
determinismHash,
Validation.TrimToNull(errorCode),
Validation.TrimToNull(error),
attempts,
Validation.TrimToNull(traceId),
Validation.TrimToNull(explainUri),
metadata ?? ImmutableSortedDictionary<string, string>.Empty,
schemaVersion)
{
}
[JsonConstructor]
public PolicyRunStatus(
string runId,
string tenantId,
string policyId,
int policyVersion,
PolicyRunMode mode,
PolicyRunExecutionStatus status,
PolicyRunPriority priority,
DateTimeOffset queuedAt,
DateTimeOffset? startedAt,
DateTimeOffset? finishedAt,
PolicyRunStats stats,
PolicyRunInputs inputs,
string? determinismHash,
string? errorCode,
string? error,
int attempts,
string? traceId,
string? explainUri,
ImmutableSortedDictionary<string, string> metadata,
string? schemaVersion = null)
{
SchemaVersion = SchedulerSchemaVersions.EnsurePolicyRunStatus(schemaVersion);
RunId = Validation.EnsureId(runId, nameof(runId));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
PolicyId = Validation.EnsureSimpleIdentifier(policyId, nameof(policyId));
if (policyVersion <= 0)
{
throw new ArgumentOutOfRangeException(nameof(policyVersion), policyVersion, "Policy version must be positive.");
}
PolicyVersion = policyVersion;
Mode = mode;
Status = status;
Priority = priority;
QueuedAt = Validation.NormalizeTimestamp(queuedAt);
StartedAt = Validation.NormalizeTimestamp(startedAt);
FinishedAt = Validation.NormalizeTimestamp(finishedAt);
Stats = stats ?? PolicyRunStats.Empty;
Inputs = inputs ?? PolicyRunInputs.Empty;
DeterminismHash = Validation.TrimToNull(determinismHash);
ErrorCode = Validation.TrimToNull(errorCode);
Error = Validation.TrimToNull(error);
Attempts = attempts < 0
? throw new ArgumentOutOfRangeException(nameof(attempts), attempts, "Attempts must be non-negative.")
: attempts;
TraceId = Validation.TrimToNull(traceId);
ExplainUri = Validation.TrimToNull(explainUri);
Metadata = (metadata ?? ImmutableSortedDictionary<string, string>.Empty)
.Select(static pair => new KeyValuePair<string, string>(
Validation.TrimToNull(pair.Key)?.ToLowerInvariant() ?? string.Empty,
Validation.TrimToNull(pair.Value) ?? string.Empty))
.Where(static pair => !string.IsNullOrEmpty(pair.Key) && !string.IsNullOrEmpty(pair.Value))
.DistinctBy(static pair => pair.Key, StringComparer.Ordinal)
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
}
public string SchemaVersion { get; }
public string RunId { get; }
public string TenantId { get; }
public string PolicyId { get; }
public int PolicyVersion { get; }
public PolicyRunMode Mode { get; }
public PolicyRunExecutionStatus Status { get; init; }
public PolicyRunPriority Priority { get; init; }
public DateTimeOffset QueuedAt { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? StartedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? FinishedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? DeterminismHash { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ErrorCode { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Attempts { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TraceId { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ExplainUri { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Metadata { get; init; } = ImmutableSortedDictionary<string, string>.Empty;
public PolicyRunStats Stats { get; init; } = PolicyRunStats.Empty;
public PolicyRunInputs Inputs { get; init; } = PolicyRunInputs.Empty;
}
/// <summary>
/// Aggregated metrics captured for a policy run.
/// </summary>
public sealed record PolicyRunStats
{
public static PolicyRunStats Empty { get; } = new();
public PolicyRunStats(
int components = 0,
int rulesFired = 0,
int findingsWritten = 0,
int vexOverrides = 0,
int quieted = 0,
int suppressed = 0,
double? durationSeconds = null)
{
Components = Validation.EnsureNonNegative(components, nameof(components));
RulesFired = Validation.EnsureNonNegative(rulesFired, nameof(rulesFired));
FindingsWritten = Validation.EnsureNonNegative(findingsWritten, nameof(findingsWritten));
VexOverrides = Validation.EnsureNonNegative(vexOverrides, nameof(vexOverrides));
Quieted = Validation.EnsureNonNegative(quieted, nameof(quieted));
Suppressed = Validation.EnsureNonNegative(suppressed, nameof(suppressed));
DurationSeconds = durationSeconds is { } seconds && seconds < 0
? throw new ArgumentOutOfRangeException(nameof(durationSeconds), durationSeconds, "Duration must be non-negative.")
: durationSeconds;
}
public int Components { get; } = 0;
public int RulesFired { get; } = 0;
public int FindingsWritten { get; } = 0;
public int VexOverrides { get; } = 0;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Quieted { get; } = 0;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Suppressed { get; } = 0;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? DurationSeconds { get; }
}
/// <summary>
/// Summary payload returned by simulations and run diffs.
/// </summary>
public sealed record PolicyDiffSummary
{
public PolicyDiffSummary(
int added,
int removed,
int unchanged,
IEnumerable<KeyValuePair<string, PolicyDiffSeverityDelta>>? bySeverity = null,
IEnumerable<PolicyDiffRuleDelta>? ruleHits = null,
string? schemaVersion = null)
: this(
Validation.EnsureNonNegative(added, nameof(added)),
Validation.EnsureNonNegative(removed, nameof(removed)),
Validation.EnsureNonNegative(unchanged, nameof(unchanged)),
NormalizeSeverity(bySeverity),
NormalizeRuleHits(ruleHits),
schemaVersion)
{
}
[JsonConstructor]
public PolicyDiffSummary(
int added,
int removed,
int unchanged,
ImmutableSortedDictionary<string, PolicyDiffSeverityDelta> bySeverity,
ImmutableArray<PolicyDiffRuleDelta> ruleHits,
string? schemaVersion = null)
{
Added = Validation.EnsureNonNegative(added, nameof(added));
Removed = Validation.EnsureNonNegative(removed, nameof(removed));
Unchanged = Validation.EnsureNonNegative(unchanged, nameof(unchanged));
BySeverity = NormalizeSeverity(bySeverity);
RuleHits = ruleHits.IsDefault ? ImmutableArray<PolicyDiffRuleDelta>.Empty : ruleHits;
SchemaVersion = SchedulerSchemaVersions.EnsurePolicyDiffSummary(schemaVersion);
}
public string SchemaVersion { get; }
public int Added { get; }
public int Removed { get; }
public int Unchanged { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, PolicyDiffSeverityDelta> BySeverity { get; } = ImmutableSortedDictionary<string, PolicyDiffSeverityDelta>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<PolicyDiffRuleDelta> RuleHits { get; } = ImmutableArray<PolicyDiffRuleDelta>.Empty;
private static ImmutableSortedDictionary<string, PolicyDiffSeverityDelta> NormalizeSeverity(IEnumerable<KeyValuePair<string, PolicyDiffSeverityDelta>>? buckets)
{
if (buckets is null)
{
return ImmutableSortedDictionary<string, PolicyDiffSeverityDelta>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, PolicyDiffSeverityDelta>(StringComparer.OrdinalIgnoreCase);
foreach (var bucket in buckets)
{
var key = Validation.TrimToNull(bucket.Key);
if (key is null)
{
continue;
}
var normalizedKey = char.ToUpperInvariant(key[0]) + key[1..].ToLowerInvariant();
builder[normalizedKey] = bucket.Value ?? PolicyDiffSeverityDelta.Empty;
}
return builder.ToImmutable();
}
private static ImmutableArray<PolicyDiffRuleDelta> NormalizeRuleHits(IEnumerable<PolicyDiffRuleDelta>? ruleHits)
{
if (ruleHits is null)
{
return ImmutableArray<PolicyDiffRuleDelta>.Empty;
}
return ruleHits
.Where(static hit => hit is not null)
.Select(static hit => hit!)
.OrderBy(static hit => hit.RuleId, StringComparer.Ordinal)
.ThenBy(static hit => hit.RuleName, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
/// Delta counts for a single severity bucket.
/// </summary>
public sealed record PolicyDiffSeverityDelta
{
public static PolicyDiffSeverityDelta Empty { get; } = new();
public PolicyDiffSeverityDelta(int up = 0, int down = 0)
{
Up = Validation.EnsureNonNegative(up, nameof(up));
Down = Validation.EnsureNonNegative(down, nameof(down));
}
public int Up { get; } = 0;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Down { get; } = 0;
}
/// <summary>
/// Delta counts per rule for simulation reporting.
/// </summary>
public sealed record PolicyDiffRuleDelta
{
public PolicyDiffRuleDelta(string ruleId, string ruleName, int up = 0, int down = 0)
{
RuleId = Validation.EnsureSimpleIdentifier(ruleId, nameof(ruleId));
RuleName = Validation.EnsureName(ruleName, nameof(ruleName));
Up = Validation.EnsureNonNegative(up, nameof(up));
Down = Validation.EnsureNonNegative(down, nameof(down));
}
public string RuleId { get; }
public string RuleName { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Up { get; } = 0;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Down { get; } = 0;
}
/// <summary>
/// Canonical explain trace for a policy finding.
/// </summary>
public sealed record PolicyExplainTrace
{
public PolicyExplainTrace(
string findingId,
string policyId,
int policyVersion,
string tenantId,
string runId,
PolicyExplainVerdict verdict,
DateTimeOffset evaluatedAt,
IEnumerable<PolicyExplainRule>? ruleChain = null,
IEnumerable<PolicyExplainEvidence>? evidence = null,
IEnumerable<PolicyExplainVexImpact>? vexImpacts = null,
IEnumerable<PolicyExplainHistoryEvent>? history = null,
ImmutableSortedDictionary<string, string>? metadata = null,
string? schemaVersion = null)
: this(
findingId,
policyId,
policyVersion,
tenantId,
runId,
Validation.NormalizeTimestamp(evaluatedAt),
verdict,
NormalizeRuleChain(ruleChain),
NormalizeEvidence(evidence),
NormalizeVexImpacts(vexImpacts),
NormalizeHistory(history),
metadata ?? ImmutableSortedDictionary<string, string>.Empty,
schemaVersion)
{
}
[JsonConstructor]
public PolicyExplainTrace(
string findingId,
string policyId,
int policyVersion,
string tenantId,
string runId,
DateTimeOffset evaluatedAt,
PolicyExplainVerdict verdict,
ImmutableArray<PolicyExplainRule> ruleChain,
ImmutableArray<PolicyExplainEvidence> evidence,
ImmutableArray<PolicyExplainVexImpact> vexImpacts,
ImmutableArray<PolicyExplainHistoryEvent> history,
ImmutableSortedDictionary<string, string> metadata,
string? schemaVersion = null)
{
SchemaVersion = SchedulerSchemaVersions.EnsurePolicyExplainTrace(schemaVersion);
FindingId = Validation.EnsureSimpleIdentifier(findingId, nameof(findingId));
PolicyId = Validation.EnsureSimpleIdentifier(policyId, nameof(policyId));
if (policyVersion <= 0)
{
throw new ArgumentOutOfRangeException(nameof(policyVersion), policyVersion, "Policy version must be positive.");
}
PolicyVersion = policyVersion;
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
RunId = Validation.EnsureId(runId, nameof(runId));
EvaluatedAt = Validation.NormalizeTimestamp(evaluatedAt);
Verdict = verdict ?? throw new ArgumentNullException(nameof(verdict));
RuleChain = ruleChain.IsDefault ? ImmutableArray<PolicyExplainRule>.Empty : ruleChain;
Evidence = evidence.IsDefault ? ImmutableArray<PolicyExplainEvidence>.Empty : evidence;
VexImpacts = vexImpacts.IsDefault ? ImmutableArray<PolicyExplainVexImpact>.Empty : vexImpacts;
History = history.IsDefault ? ImmutableArray<PolicyExplainHistoryEvent>.Empty : history;
Metadata = (metadata ?? ImmutableSortedDictionary<string, string>.Empty)
.Select(static pair => new KeyValuePair<string, string>(
Validation.TrimToNull(pair.Key)?.ToLowerInvariant() ?? string.Empty,
Validation.TrimToNull(pair.Value) ?? string.Empty))
.Where(static pair => !string.IsNullOrEmpty(pair.Key) && !string.IsNullOrEmpty(pair.Value))
.DistinctBy(static pair => pair.Key, StringComparer.Ordinal)
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
}
public string SchemaVersion { get; }
public string FindingId { get; }
public string PolicyId { get; }
public int PolicyVersion { get; }
public string TenantId { get; }
public string RunId { get; }
public DateTimeOffset EvaluatedAt { get; }
public PolicyExplainVerdict Verdict { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<PolicyExplainRule> RuleChain { get; } = ImmutableArray<PolicyExplainRule>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<PolicyExplainEvidence> Evidence { get; } = ImmutableArray<PolicyExplainEvidence>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<PolicyExplainVexImpact> VexImpacts { get; } = ImmutableArray<PolicyExplainVexImpact>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<PolicyExplainHistoryEvent> History { get; } = ImmutableArray<PolicyExplainHistoryEvent>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
private static ImmutableArray<PolicyExplainRule> NormalizeRuleChain(IEnumerable<PolicyExplainRule>? rules)
{
if (rules is null)
{
return ImmutableArray<PolicyExplainRule>.Empty;
}
return rules
.Where(static rule => rule is not null)
.Select(static rule => rule!)
.ToImmutableArray();
}
private static ImmutableArray<PolicyExplainEvidence> NormalizeEvidence(IEnumerable<PolicyExplainEvidence>? evidence)
{
if (evidence is null)
{
return ImmutableArray<PolicyExplainEvidence>.Empty;
}
return evidence
.Where(static item => item is not null)
.Select(static item => item!)
.OrderBy(static item => item.Type, StringComparer.Ordinal)
.ThenBy(static item => item.Reference, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<PolicyExplainVexImpact> NormalizeVexImpacts(IEnumerable<PolicyExplainVexImpact>? impacts)
{
if (impacts is null)
{
return ImmutableArray<PolicyExplainVexImpact>.Empty;
}
return impacts
.Where(static impact => impact is not null)
.Select(static impact => impact!)
.OrderBy(static impact => impact.StatementId, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<PolicyExplainHistoryEvent> NormalizeHistory(IEnumerable<PolicyExplainHistoryEvent>? history)
{
if (history is null)
{
return ImmutableArray<PolicyExplainHistoryEvent>.Empty;
}
return history
.Where(static entry => entry is not null)
.Select(static entry => entry!)
.OrderBy(static entry => entry.OccurredAt)
.ToImmutableArray();
}
}
/// <summary>
/// Verdict metadata for explain traces.
/// </summary>
public sealed record PolicyExplainVerdict
{
public PolicyExplainVerdict(
PolicyVerdictStatus status,
SeverityRank? severity = null,
bool quiet = false,
double? score = null,
string? rationale = null)
{
Status = status;
Severity = severity;
Quiet = quiet;
Score = score;
Rationale = Validation.TrimToNull(rationale);
}
public PolicyVerdictStatus Status { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public SeverityRank? Severity { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool Quiet { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Score { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Rationale { get; }
}
/// <summary>
/// Rule evaluation entry captured in explain traces.
/// </summary>
public sealed record PolicyExplainRule
{
public PolicyExplainRule(
string ruleId,
string ruleName,
string action,
string decision,
double score,
string? condition = null)
{
RuleId = Validation.EnsureSimpleIdentifier(ruleId, nameof(ruleId));
RuleName = Validation.EnsureName(ruleName, nameof(ruleName));
Action = Validation.TrimToNull(action) ?? throw new ArgumentNullException(nameof(action));
Decision = Validation.TrimToNull(decision) ?? throw new ArgumentNullException(nameof(decision));
Score = score;
Condition = Validation.TrimToNull(condition);
}
public string RuleId { get; }
public string RuleName { get; }
public string Action { get; }
public string Decision { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public double Score { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Condition { get; }
}
/// <summary>
/// Evidence entry considered during policy evaluation.
/// </summary>
public sealed record PolicyExplainEvidence
{
public PolicyExplainEvidence(
string type,
string reference,
string source,
string status,
double weight = 0,
string? justification = null,
ImmutableSortedDictionary<string, string>? metadata = null)
{
Type = Validation.TrimToNull(type) ?? throw new ArgumentNullException(nameof(type));
Reference = Validation.TrimToNull(reference) ?? throw new ArgumentNullException(nameof(reference));
Source = Validation.TrimToNull(source) ?? throw new ArgumentNullException(nameof(source));
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
Weight = weight;
Justification = Validation.TrimToNull(justification);
Metadata = (metadata ?? ImmutableSortedDictionary<string, string>.Empty)
.Select(static pair => new KeyValuePair<string, string>(
Validation.TrimToNull(pair.Key)?.ToLowerInvariant() ?? string.Empty,
Validation.TrimToNull(pair.Value) ?? string.Empty))
.Where(static pair => !string.IsNullOrEmpty(pair.Key) && !string.IsNullOrEmpty(pair.Value))
.DistinctBy(static pair => pair.Key, StringComparer.Ordinal)
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
}
public string Type { get; }
public string Reference { get; }
public string Source { get; }
public string Status { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public double Weight { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Justification { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
}
/// <summary>
/// VEX statement impact summary captured in explain traces.
/// </summary>
public sealed record PolicyExplainVexImpact
{
public PolicyExplainVexImpact(
string statementId,
string provider,
string status,
bool accepted,
string? justification = null,
string? confidence = null)
{
StatementId = Validation.TrimToNull(statementId) ?? throw new ArgumentNullException(nameof(statementId));
Provider = Validation.TrimToNull(provider) ?? throw new ArgumentNullException(nameof(provider));
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
Accepted = accepted;
Justification = Validation.TrimToNull(justification);
Confidence = Validation.TrimToNull(confidence);
}
public string StatementId { get; }
public string Provider { get; }
public string Status { get; }
public bool Accepted { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Justification { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Confidence { get; }
}
/// <summary>
/// History entry for a finding's policy lifecycle.
/// </summary>
public sealed record PolicyExplainHistoryEvent
{
public PolicyExplainHistoryEvent(
string status,
DateTimeOffset occurredAt,
string? actor = null,
string? note = null)
{
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
OccurredAt = Validation.NormalizeTimestamp(occurredAt);
Actor = Validation.TrimToNull(actor);
Note = Validation.TrimToNull(note);
}
public string Status { get; }
public DateTimeOffset OccurredAt { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Actor { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Note { get; }
}

View File

@@ -0,0 +1,378 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Execution record for a scheduler run.
/// </summary>
public sealed record Run
{
public Run(
string id,
string tenantId,
RunTrigger trigger,
RunState state,
RunStats stats,
DateTimeOffset createdAt,
RunReason? reason = null,
string? scheduleId = null,
DateTimeOffset? startedAt = null,
DateTimeOffset? finishedAt = null,
string? error = null,
IEnumerable<DeltaSummary>? deltas = null,
string? schemaVersion = null)
: this(
id,
tenantId,
trigger,
state,
stats,
reason ?? RunReason.Empty,
scheduleId,
Validation.NormalizeTimestamp(createdAt),
Validation.NormalizeTimestamp(startedAt),
Validation.NormalizeTimestamp(finishedAt),
Validation.TrimToNull(error),
NormalizeDeltas(deltas),
schemaVersion)
{
}
[JsonConstructor]
public Run(
string id,
string tenantId,
RunTrigger trigger,
RunState state,
RunStats stats,
RunReason reason,
string? scheduleId,
DateTimeOffset createdAt,
DateTimeOffset? startedAt,
DateTimeOffset? finishedAt,
string? error,
ImmutableArray<DeltaSummary> deltas,
string? schemaVersion = null)
{
Id = Validation.EnsureId(id, nameof(id));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
Trigger = trigger;
State = state;
Stats = stats ?? throw new ArgumentNullException(nameof(stats));
Reason = reason ?? RunReason.Empty;
ScheduleId = Validation.TrimToNull(scheduleId);
CreatedAt = Validation.NormalizeTimestamp(createdAt);
StartedAt = Validation.NormalizeTimestamp(startedAt);
FinishedAt = Validation.NormalizeTimestamp(finishedAt);
Error = Validation.TrimToNull(error);
Deltas = deltas.IsDefault
? ImmutableArray<DeltaSummary>.Empty
: deltas.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal).ToImmutableArray();
SchemaVersion = SchedulerSchemaVersions.EnsureRun(schemaVersion);
}
public string SchemaVersion { get; }
public string Id { get; }
public string TenantId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ScheduleId { get; }
public RunTrigger Trigger { get; }
public RunState State { get; init; }
public RunStats Stats { get; init; }
public RunReason Reason { get; }
public DateTimeOffset CreatedAt { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? StartedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? FinishedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<DeltaSummary> Deltas { get; } = ImmutableArray<DeltaSummary>.Empty;
private static ImmutableArray<DeltaSummary> NormalizeDeltas(IEnumerable<DeltaSummary>? deltas)
{
if (deltas is null)
{
return ImmutableArray<DeltaSummary>.Empty;
}
return deltas
.Where(static delta => delta is not null)
.Select(static delta => delta!)
.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
/// Context describing why a run executed.
/// </summary>
public sealed record RunReason
{
public static RunReason Empty { get; } = new();
public RunReason(
string? manualReason = null,
string? feedserExportId = null,
string? vexerExportId = null,
string? cursor = null)
{
ManualReason = Validation.TrimToNull(manualReason);
FeedserExportId = Validation.TrimToNull(feedserExportId);
VexerExportId = Validation.TrimToNull(vexerExportId);
Cursor = Validation.TrimToNull(cursor);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ManualReason { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FeedserExportId { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? VexerExportId { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Cursor { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ImpactWindowFrom { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ImpactWindowTo { get; init; }
}
/// <summary>
/// Aggregated counters for a scheduler run.
/// </summary>
public sealed record RunStats
{
public static RunStats Empty { get; } = new();
public RunStats(
int candidates = 0,
int deduped = 0,
int queued = 0,
int completed = 0,
int deltas = 0,
int newCriticals = 0,
int newHigh = 0,
int newMedium = 0,
int newLow = 0)
{
Candidates = Validation.EnsureNonNegative(candidates, nameof(candidates));
Deduped = Validation.EnsureNonNegative(deduped, nameof(deduped));
Queued = Validation.EnsureNonNegative(queued, nameof(queued));
Completed = Validation.EnsureNonNegative(completed, nameof(completed));
Deltas = Validation.EnsureNonNegative(deltas, nameof(deltas));
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
}
public int Candidates { get; } = 0;
public int Deduped { get; } = 0;
public int Queued { get; } = 0;
public int Completed { get; } = 0;
public int Deltas { get; } = 0;
public int NewCriticals { get; } = 0;
public int NewHigh { get; } = 0;
public int NewMedium { get; } = 0;
public int NewLow { get; } = 0;
}
/// <summary>
/// Snapshot of delta impact for an image processed in a run.
/// </summary>
public sealed record DeltaSummary
{
public DeltaSummary(
string imageDigest,
int newFindings,
int newCriticals,
int newHigh,
int newMedium,
int newLow,
IEnumerable<string>? kevHits = null,
IEnumerable<DeltaFinding>? topFindings = null,
string? reportUrl = null,
DeltaAttestation? attestation = null,
DateTimeOffset? detectedAt = null)
: this(
imageDigest,
Validation.EnsureNonNegative(newFindings, nameof(newFindings)),
Validation.EnsureNonNegative(newCriticals, nameof(newCriticals)),
Validation.EnsureNonNegative(newHigh, nameof(newHigh)),
Validation.EnsureNonNegative(newMedium, nameof(newMedium)),
Validation.EnsureNonNegative(newLow, nameof(newLow)),
NormalizeKevHits(kevHits),
NormalizeFindings(topFindings),
Validation.TrimToNull(reportUrl),
attestation,
Validation.NormalizeTimestamp(detectedAt))
{
}
[JsonConstructor]
public DeltaSummary(
string imageDigest,
int newFindings,
int newCriticals,
int newHigh,
int newMedium,
int newLow,
ImmutableArray<string> kevHits,
ImmutableArray<DeltaFinding> topFindings,
string? reportUrl,
DeltaAttestation? attestation,
DateTimeOffset? detectedAt)
{
ImageDigest = Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest));
NewFindings = Validation.EnsureNonNegative(newFindings, nameof(newFindings));
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
KevHits = kevHits.IsDefault ? ImmutableArray<string>.Empty : kevHits;
TopFindings = topFindings.IsDefault
? ImmutableArray<DeltaFinding>.Empty
: topFindings
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
.ToImmutableArray();
ReportUrl = Validation.TrimToNull(reportUrl);
Attestation = attestation;
DetectedAt = Validation.NormalizeTimestamp(detectedAt);
}
public string ImageDigest { get; }
public int NewFindings { get; }
public int NewCriticals { get; }
public int NewHigh { get; }
public int NewMedium { get; }
public int NewLow { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> KevHits { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<DeltaFinding> TopFindings { get; } = ImmutableArray<DeltaFinding>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReportUrl { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DeltaAttestation? Attestation { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? DetectedAt { get; }
private static ImmutableArray<string> NormalizeKevHits(IEnumerable<string>? kevHits)
=> Validation.NormalizeStringSet(kevHits, nameof(kevHits));
private static ImmutableArray<DeltaFinding> NormalizeFindings(IEnumerable<DeltaFinding>? findings)
{
if (findings is null)
{
return ImmutableArray<DeltaFinding>.Empty;
}
return findings
.Where(static finding => finding is not null)
.Select(static finding => finding!)
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
/// Top finding entry included in delta summaries.
/// </summary>
public sealed record DeltaFinding
{
public DeltaFinding(string purl, string vulnerabilityId, SeverityRank severity, string? link = null)
{
Purl = Validation.EnsureSimpleIdentifier(purl, nameof(purl));
VulnerabilityId = Validation.EnsureSimpleIdentifier(vulnerabilityId, nameof(vulnerabilityId));
Severity = severity;
Link = Validation.TrimToNull(link);
}
public string Purl { get; }
public string VulnerabilityId { get; }
public SeverityRank Severity { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Link { get; }
}
/// <summary>
/// Rekor/attestation information surfaced with a delta summary.
/// </summary>
public sealed record DeltaAttestation
{
public DeltaAttestation(string? uuid, bool? verified = null)
{
Uuid = Validation.TrimToNull(uuid);
Verified = verified;
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Uuid { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Verified { get; }
}
internal sealed class SeverityRankComparer : IComparer<SeverityRank>
{
public static SeverityRankComparer Instance { get; } = new();
private static readonly Dictionary<SeverityRank, int> Order = new()
{
[SeverityRank.Critical] = 0,
[SeverityRank.High] = 1,
[SeverityRank.Unknown] = 2,
[SeverityRank.Medium] = 3,
[SeverityRank.Low] = 4,
[SeverityRank.Info] = 5,
[SeverityRank.None] = 6,
};
public int Compare(SeverityRank x, SeverityRank y)
=> GetOrder(x).CompareTo(GetOrder(y));
private static int GetOrder(SeverityRank severity)
=> Order.TryGetValue(severity, out var value) ? value : int.MaxValue;
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Globalization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Convenience helpers for <see cref="RunReason"/> mutations.
/// </summary>
public static class RunReasonExtensions
{
/// <summary>
/// Returns a copy of <paramref name="reason"/> with impact window timestamps normalized to ISO-8601.
/// </summary>
public static RunReason WithImpactWindow(
this RunReason reason,
DateTimeOffset? from,
DateTimeOffset? to)
{
var normalizedFrom = Validation.NormalizeTimestamp(from);
var normalizedTo = Validation.NormalizeTimestamp(to);
if (normalizedFrom.HasValue && normalizedTo.HasValue && normalizedFrom > normalizedTo)
{
throw new ArgumentException("Impact window start must be earlier than or equal to end.");
}
return reason with
{
ImpactWindowFrom = normalizedFrom?.ToString("O", CultureInfo.InvariantCulture),
ImpactWindowTo = normalizedTo?.ToString("O", CultureInfo.InvariantCulture),
};
}
}

View File

@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Encapsulates allowed <see cref="RunState"/> transitions and invariants.
/// </summary>
public static class RunStateMachine
{
private static readonly IReadOnlyDictionary<RunState, RunState[]> Adjacency = new Dictionary<RunState, RunState[]>
{
[RunState.Planning] = new[] { RunState.Planning, RunState.Queued, RunState.Cancelled },
[RunState.Queued] = new[] { RunState.Queued, RunState.Running, RunState.Cancelled },
[RunState.Running] = new[] { RunState.Running, RunState.Completed, RunState.Error, RunState.Cancelled },
[RunState.Completed] = new[] { RunState.Completed },
[RunState.Error] = new[] { RunState.Error },
[RunState.Cancelled] = new[] { RunState.Cancelled },
};
public static bool CanTransition(RunState from, RunState to)
{
if (!Adjacency.TryGetValue(from, out var allowed))
{
return false;
}
return allowed.Contains(to);
}
public static bool IsTerminal(RunState state)
=> state is RunState.Completed or RunState.Error or RunState.Cancelled;
/// <summary>
/// Applies a state transition ensuring timestamps, stats, and error contracts stay consistent.
/// </summary>
public static Run EnsureTransition(
Run run,
RunState next,
DateTimeOffset timestamp,
Action<RunStatsBuilder>? mutateStats = null,
string? errorMessage = null)
{
ArgumentNullException.ThrowIfNull(run);
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
var current = run.State;
if (!CanTransition(current, next))
{
throw new InvalidOperationException($"Run state transition from '{current}' to '{next}' is not allowed.");
}
var statsBuilder = new RunStatsBuilder(run.Stats);
mutateStats?.Invoke(statsBuilder);
var newStats = statsBuilder.Build();
var startedAt = run.StartedAt;
var finishedAt = run.FinishedAt;
if (current != RunState.Running && next == RunState.Running && startedAt is null)
{
startedAt = normalizedTimestamp;
}
if (IsTerminal(next))
{
finishedAt ??= normalizedTimestamp;
}
if (startedAt is { } start && start < run.CreatedAt)
{
throw new InvalidOperationException("Run started time cannot be earlier than created time.");
}
if (finishedAt is { } finish)
{
if (startedAt is { } startTime && finish < startTime)
{
throw new InvalidOperationException("Run finished time cannot be earlier than start time.");
}
if (!IsTerminal(next))
{
throw new InvalidOperationException("Finished time present but next state is not terminal.");
}
}
string? nextError = null;
if (next == RunState.Error)
{
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? run.Error : errorMessage.Trim();
if (string.IsNullOrWhiteSpace(effectiveError))
{
throw new InvalidOperationException("Transitioning to Error requires a non-empty error message.");
}
nextError = effectiveError;
}
else if (!string.IsNullOrWhiteSpace(errorMessage))
{
throw new InvalidOperationException("Error message can only be provided when transitioning to Error state.");
}
var updated = run with
{
State = next,
Stats = newStats,
StartedAt = startedAt,
FinishedAt = finishedAt,
Error = nextError,
};
Validate(updated);
return updated;
}
public static void Validate(Run run)
{
ArgumentNullException.ThrowIfNull(run);
if (run.StartedAt is { } started && started < run.CreatedAt)
{
throw new InvalidOperationException("Run.StartedAt cannot be earlier than CreatedAt.");
}
if (run.FinishedAt is { } finished)
{
if (run.StartedAt is { } startedAt && finished < startedAt)
{
throw new InvalidOperationException("Run.FinishedAt cannot be earlier than StartedAt.");
}
if (!IsTerminal(run.State))
{
throw new InvalidOperationException("Run.FinishedAt set while state is not terminal.");
}
}
else if (IsTerminal(run.State))
{
throw new InvalidOperationException("Terminal run states must include FinishedAt.");
}
if (run.State == RunState.Error)
{
if (string.IsNullOrWhiteSpace(run.Error))
{
throw new InvalidOperationException("Run.Error must be populated when state is Error.");
}
}
else if (!string.IsNullOrWhiteSpace(run.Error))
{
throw new InvalidOperationException("Run.Error must be null for non-error states.");
}
}
}

View File

@@ -0,0 +1,92 @@
using System;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Helper that enforces monotonic <see cref="RunStats"/> updates.
/// </summary>
public sealed class RunStatsBuilder
{
private int _candidates;
private int _deduped;
private int _queued;
private int _completed;
private int _deltas;
private int _newCriticals;
private int _newHigh;
private int _newMedium;
private int _newLow;
public RunStatsBuilder(RunStats? baseline = null)
{
baseline ??= RunStats.Empty;
_candidates = baseline.Candidates;
_deduped = baseline.Deduped;
_queued = baseline.Queued;
_completed = baseline.Completed;
_deltas = baseline.Deltas;
_newCriticals = baseline.NewCriticals;
_newHigh = baseline.NewHigh;
_newMedium = baseline.NewMedium;
_newLow = baseline.NewLow;
}
public void SetCandidates(int value) => _candidates = EnsureMonotonic(value, _candidates, nameof(RunStats.Candidates));
public void IncrementCandidates(int value = 1) => SetCandidates(_candidates + value);
public void SetDeduped(int value) => _deduped = EnsureMonotonic(value, _deduped, nameof(RunStats.Deduped));
public void IncrementDeduped(int value = 1) => SetDeduped(_deduped + value);
public void SetQueued(int value) => _queued = EnsureMonotonic(value, _queued, nameof(RunStats.Queued));
public void IncrementQueued(int value = 1) => SetQueued(_queued + value);
public void SetCompleted(int value) => _completed = EnsureMonotonic(value, _completed, nameof(RunStats.Completed));
public void IncrementCompleted(int value = 1) => SetCompleted(_completed + value);
public void SetDeltas(int value) => _deltas = EnsureMonotonic(value, _deltas, nameof(RunStats.Deltas));
public void IncrementDeltas(int value = 1) => SetDeltas(_deltas + value);
public void SetNewCriticals(int value) => _newCriticals = EnsureMonotonic(value, _newCriticals, nameof(RunStats.NewCriticals));
public void IncrementNewCriticals(int value = 1) => SetNewCriticals(_newCriticals + value);
public void SetNewHigh(int value) => _newHigh = EnsureMonotonic(value, _newHigh, nameof(RunStats.NewHigh));
public void IncrementNewHigh(int value = 1) => SetNewHigh(_newHigh + value);
public void SetNewMedium(int value) => _newMedium = EnsureMonotonic(value, _newMedium, nameof(RunStats.NewMedium));
public void IncrementNewMedium(int value = 1) => SetNewMedium(_newMedium + value);
public void SetNewLow(int value) => _newLow = EnsureMonotonic(value, _newLow, nameof(RunStats.NewLow));
public void IncrementNewLow(int value = 1) => SetNewLow(_newLow + value);
public RunStats Build()
=> new(
candidates: _candidates,
deduped: _deduped,
queued: _queued,
completed: _completed,
deltas: _deltas,
newCriticals: _newCriticals,
newHigh: _newHigh,
newMedium: _newMedium,
newLow: _newLow);
private static int EnsureMonotonic(int value, int current, string fieldName)
{
Validation.EnsureNonNegative(value, fieldName);
if (value < current)
{
throw new InvalidOperationException($"RunStats.{fieldName} cannot decrease (current: {current}, attempted: {value}).");
}
return value;
}
}

View File

@@ -0,0 +1,227 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Scheduler configuration entity persisted in Mongo.
/// </summary>
public sealed record Schedule
{
public Schedule(
string id,
string tenantId,
string name,
bool enabled,
string cronExpression,
string timezone,
ScheduleMode mode,
Selector selection,
ScheduleOnlyIf? onlyIf,
ScheduleNotify? notify,
ScheduleLimits? limits,
DateTimeOffset createdAt,
string createdBy,
DateTimeOffset updatedAt,
string updatedBy,
ImmutableArray<string>? subscribers = null,
string? schemaVersion = null)
: this(
id,
tenantId,
name,
enabled,
cronExpression,
timezone,
mode,
selection,
onlyIf ?? ScheduleOnlyIf.Default,
notify ?? ScheduleNotify.Default,
limits ?? ScheduleLimits.Default,
subscribers ?? ImmutableArray<string>.Empty,
createdAt,
createdBy,
updatedAt,
updatedBy,
schemaVersion)
{
}
[JsonConstructor]
public Schedule(
string id,
string tenantId,
string name,
bool enabled,
string cronExpression,
string timezone,
ScheduleMode mode,
Selector selection,
ScheduleOnlyIf onlyIf,
ScheduleNotify notify,
ScheduleLimits limits,
ImmutableArray<string> subscribers,
DateTimeOffset createdAt,
string createdBy,
DateTimeOffset updatedAt,
string updatedBy,
string? schemaVersion = null)
{
Id = Validation.EnsureId(id, nameof(id));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
Name = Validation.EnsureName(name, nameof(name));
Enabled = enabled;
CronExpression = Validation.EnsureCronExpression(cronExpression, nameof(cronExpression));
Timezone = Validation.EnsureTimezone(timezone, nameof(timezone));
Mode = mode;
Selection = selection ?? throw new ArgumentNullException(nameof(selection));
OnlyIf = onlyIf ?? ScheduleOnlyIf.Default;
Notify = notify ?? ScheduleNotify.Default;
Limits = limits ?? ScheduleLimits.Default;
Subscribers = (subscribers.IsDefault ? ImmutableArray<string>.Empty : subscribers)
.Select(static value => Validation.EnsureSimpleIdentifier(value, nameof(subscribers)))
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray();
CreatedAt = Validation.NormalizeTimestamp(createdAt);
CreatedBy = Validation.EnsureSimpleIdentifier(createdBy, nameof(createdBy));
UpdatedAt = Validation.NormalizeTimestamp(updatedAt);
UpdatedBy = Validation.EnsureSimpleIdentifier(updatedBy, nameof(updatedBy));
SchemaVersion = SchedulerSchemaVersions.EnsureSchedule(schemaVersion);
if (Selection.TenantId is not null && !string.Equals(Selection.TenantId, TenantId, StringComparison.Ordinal))
{
throw new ArgumentException("Selection tenant must match schedule tenant.", nameof(selection));
}
}
public string SchemaVersion { get; }
public string Id { get; }
public string TenantId { get; }
public string Name { get; }
public bool Enabled { get; }
public string CronExpression { get; }
public string Timezone { get; }
public ScheduleMode Mode { get; }
public Selector Selection { get; }
public ScheduleOnlyIf OnlyIf { get; }
public ScheduleNotify Notify { get; }
public ScheduleLimits Limits { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Subscribers { get; } = ImmutableArray<string>.Empty;
public DateTimeOffset CreatedAt { get; }
public string CreatedBy { get; }
public DateTimeOffset UpdatedAt { get; }
public string UpdatedBy { get; }
}
/// <summary>
/// Conditions that must hold before a schedule enqueues work.
/// </summary>
public sealed record ScheduleOnlyIf
{
public static ScheduleOnlyIf Default { get; } = new();
[JsonConstructor]
public ScheduleOnlyIf(int? lastReportOlderThanDays = null, string? policyRevision = null)
{
LastReportOlderThanDays = Validation.EnsurePositiveOrNull(lastReportOlderThanDays, nameof(lastReportOlderThanDays));
PolicyRevision = Validation.TrimToNull(policyRevision);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? LastReportOlderThanDays { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PolicyRevision { get; } = null;
}
/// <summary>
/// Notification preferences for schedule outcomes.
/// </summary>
public sealed record ScheduleNotify
{
public static ScheduleNotify Default { get; } = new(onNewFindings: true, null, includeKev: true);
public ScheduleNotify(bool onNewFindings, SeverityRank? minSeverity, bool includeKev)
{
OnNewFindings = onNewFindings;
if (minSeverity is SeverityRank.Unknown or SeverityRank.None)
{
MinSeverity = minSeverity == SeverityRank.Unknown ? SeverityRank.Unknown : SeverityRank.Low;
}
else
{
MinSeverity = minSeverity;
}
IncludeKev = includeKev;
}
[JsonConstructor]
public ScheduleNotify(bool onNewFindings, SeverityRank? minSeverity, bool includeKev, bool includeQuietFindings = false)
: this(onNewFindings, minSeverity, includeKev)
{
IncludeQuietFindings = includeQuietFindings;
}
public bool OnNewFindings { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public SeverityRank? MinSeverity { get; }
public bool IncludeKev { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool IncludeQuietFindings { get; }
}
/// <summary>
/// Execution limits that bound scheduler throughput.
/// </summary>
public sealed record ScheduleLimits
{
public static ScheduleLimits Default { get; } = new();
public ScheduleLimits(int? maxJobs = null, int? ratePerSecond = null, int? parallelism = null)
{
MaxJobs = Validation.EnsurePositiveOrNull(maxJobs, nameof(maxJobs));
RatePerSecond = Validation.EnsurePositiveOrNull(ratePerSecond, nameof(ratePerSecond));
Parallelism = Validation.EnsurePositiveOrNull(parallelism, nameof(parallelism));
}
[JsonConstructor]
public ScheduleLimits(int? maxJobs, int? ratePerSecond, int? parallelism, int? burst = null)
: this(maxJobs, ratePerSecond, parallelism)
{
Burst = Validation.EnsurePositiveOrNull(burst, nameof(burst));
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? MaxJobs { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? RatePerSecond { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Parallelism { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Burst { get; }
}

View File

@@ -0,0 +1,454 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Upgrades scheduler documents emitted by earlier schema revisions to the latest DTOs.
/// </summary>
public static class SchedulerSchemaMigration
{
private static readonly ImmutableHashSet<string> ScheduleProperties = ImmutableHashSet.Create(
StringComparer.Ordinal,
"schemaVersion",
"id",
"tenantId",
"name",
"enabled",
"cronExpression",
"timezone",
"mode",
"selection",
"onlyIf",
"notify",
"limits",
"subscribers",
"createdAt",
"createdBy",
"updatedAt",
"updatedBy");
private static readonly ImmutableHashSet<string> RunProperties = ImmutableHashSet.Create(
StringComparer.Ordinal,
"schemaVersion",
"id",
"tenantId",
"scheduleId",
"trigger",
"state",
"stats",
"reason",
"createdAt",
"startedAt",
"finishedAt",
"error",
"deltas");
private static readonly ImmutableHashSet<string> ImpactSetProperties = ImmutableHashSet.Create(
StringComparer.Ordinal,
"schemaVersion",
"selector",
"images",
"usageOnly",
"generatedAt",
"total",
"snapshotId");
public static SchedulerSchemaMigrationResult<Schedule> UpgradeSchedule(JsonNode document, bool strict = false)
=> Upgrade(
document,
SchedulerSchemaVersions.Schedule,
SchedulerSchemaVersions.EnsureSchedule,
ScheduleProperties,
static json => CanonicalJsonSerializer.Deserialize<Schedule>(json),
ApplyScheduleLegacyFixups,
strict);
public static SchedulerSchemaMigrationResult<Run> UpgradeRun(JsonNode document, bool strict = false)
=> Upgrade(
document,
SchedulerSchemaVersions.Run,
SchedulerSchemaVersions.EnsureRun,
RunProperties,
static json => CanonicalJsonSerializer.Deserialize<Run>(json),
ApplyRunLegacyFixups,
strict);
public static SchedulerSchemaMigrationResult<ImpactSet> UpgradeImpactSet(JsonNode document, bool strict = false)
=> Upgrade(
document,
SchedulerSchemaVersions.ImpactSet,
SchedulerSchemaVersions.EnsureImpactSet,
ImpactSetProperties,
static json => CanonicalJsonSerializer.Deserialize<ImpactSet>(json),
ApplyImpactSetLegacyFixups,
strict);
private static SchedulerSchemaMigrationResult<T> Upgrade<T>(
JsonNode document,
string latestVersion,
Func<string?, string> ensureVersion,
ImmutableHashSet<string> knownProperties,
Func<string, T> deserialize,
Func<JsonObject, string, ImmutableArray<string>.Builder, bool> applyLegacyFixups,
bool strict)
{
ArgumentNullException.ThrowIfNull(document);
var (normalized, fromVersion) = Normalize(document, ensureVersion);
var warnings = ImmutableArray.CreateBuilder<string>();
if (!string.Equals(fromVersion, latestVersion, StringComparison.Ordinal))
{
var upgraded = applyLegacyFixups(normalized, fromVersion, warnings);
if (!upgraded)
{
throw new NotSupportedException($"Unsupported scheduler schema version '{fromVersion}', expected '{latestVersion}'.");
}
normalized["schemaVersion"] = latestVersion;
}
if (strict)
{
RemoveUnknownMembers(normalized, knownProperties, warnings, fromVersion);
}
var canonicalJson = normalized.ToJsonString(new JsonSerializerOptions
{
WriteIndented = false,
});
var value = deserialize(canonicalJson);
return new SchedulerSchemaMigrationResult<T>(
value,
fromVersion,
latestVersion,
warnings.ToImmutable());
}
private static (JsonObject Clone, string SchemaVersion) Normalize(JsonNode node, Func<string?, string> ensureVersion)
{
if (node is not JsonObject obj)
{
throw new ArgumentException("Document must be a JSON object.", nameof(node));
}
if (obj.DeepClone() is not JsonObject clone)
{
throw new InvalidOperationException("Unable to clone scheduler document.");
}
string schemaVersion;
if (clone.TryGetPropertyValue("schemaVersion", out var value) &&
value is JsonValue jsonValue &&
jsonValue.TryGetValue(out string? rawVersion))
{
schemaVersion = ensureVersion(rawVersion);
}
else
{
schemaVersion = ensureVersion(null);
clone["schemaVersion"] = schemaVersion;
}
// Ensure schemaVersion is normalized in the clone.
clone["schemaVersion"] = schemaVersion;
return (clone, schemaVersion);
}
private static void RemoveUnknownMembers(
JsonObject json,
ImmutableHashSet<string> knownProperties,
ImmutableArray<string>.Builder warnings,
string schemaVersion)
{
var unknownKeys = json
.Where(static pair => pair.Key is not null)
.Select(pair => pair.Key!)
.Where(key => !knownProperties.Contains(key))
.ToArray();
foreach (var key in unknownKeys)
{
json.Remove(key);
warnings.Add($"Removed unknown property '{key}' from scheduler document (schemaVersion={schemaVersion}).");
}
}
private static bool ApplyScheduleLegacyFixups(
JsonObject json,
string fromVersion,
ImmutableArray<string>.Builder warnings)
{
switch (fromVersion)
{
case SchedulerSchemaVersions.ScheduleLegacy0:
var limits = EnsureObject(json, "limits", () => new JsonObject(), warnings, "schedule", fromVersion);
NormalizePositiveInt(limits, "maxJobs", warnings, "schedule.limits", fromVersion);
NormalizePositiveInt(limits, "ratePerSecond", warnings, "schedule.limits", fromVersion);
NormalizePositiveInt(limits, "parallelism", warnings, "schedule.limits", fromVersion);
NormalizePositiveInt(limits, "burst", warnings, "schedule.limits", fromVersion);
var notify = EnsureObject(json, "notify", () => new JsonObject(), warnings, "schedule", fromVersion);
NormalizeBoolean(notify, "onNewFindings", defaultValue: true, warnings, "schedule.notify", fromVersion);
NormalizeSeverity(notify, "minSeverity", warnings, "schedule.notify", fromVersion);
NormalizeBoolean(notify, "includeKev", defaultValue: true, warnings, "schedule.notify", fromVersion);
NormalizeBoolean(notify, "includeQuietFindings", defaultValue: false, warnings, "schedule.notify", fromVersion);
var onlyIf = EnsureObject(json, "onlyIf", () => new JsonObject(), warnings, "schedule", fromVersion);
NormalizePositiveInt(onlyIf, "lastReportOlderThanDays", warnings, "schedule.onlyIf", fromVersion, allowZero: false);
EnsureArray(json, "subscribers", warnings, "schedule", fromVersion);
return true;
default:
return false;
}
}
private static bool ApplyRunLegacyFixups(
JsonObject json,
string fromVersion,
ImmutableArray<string>.Builder warnings)
{
switch (fromVersion)
{
case SchedulerSchemaVersions.RunLegacy0:
var stats = EnsureObject(json, "stats", () => new JsonObject(), warnings, "run", fromVersion);
NormalizeNonNegativeInt(stats, "candidates", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "deduped", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "queued", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "completed", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "deltas", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "newCriticals", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "newHigh", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "newMedium", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "newLow", warnings, "run.stats", fromVersion);
EnsureObject(json, "reason", () => new JsonObject(), warnings, "run", fromVersion);
EnsureArray(json, "deltas", warnings, "run", fromVersion);
return true;
default:
return false;
}
}
private static bool ApplyImpactSetLegacyFixups(
JsonObject json,
string fromVersion,
ImmutableArray<string>.Builder warnings)
{
switch (fromVersion)
{
case SchedulerSchemaVersions.ImpactSetLegacy0:
var images = EnsureArray(json, "images", warnings, "impact-set", fromVersion);
NormalizeBoolean(json, "usageOnly", defaultValue: false, warnings, "impact-set", fromVersion);
if (!json.TryGetPropertyValue("total", out var totalNode) || !TryReadNonNegative(totalNode, out var total))
{
var computed = images.Count;
json["total"] = computed;
warnings.Add($"Backfilled impact set total with image count ({computed}) while upgrading from {fromVersion}.");
}
else
{
var computed = images.Count;
if (total != computed)
{
json["total"] = computed;
warnings.Add($"Normalized impact set total to image count ({computed}) while upgrading from {fromVersion}.");
}
}
return true;
default:
return false;
}
}
private static JsonObject EnsureObject(
JsonObject parent,
string propertyName,
Func<JsonObject> factory,
ImmutableArray<string>.Builder warnings,
string context,
string fromVersion)
{
if (parent.TryGetPropertyValue(propertyName, out var node) && node is JsonObject obj)
{
return obj;
}
var created = factory();
parent[propertyName] = created;
warnings.Add($"Inserted default '{context}.{propertyName}' object while upgrading from {fromVersion}.");
return created;
}
private static JsonArray EnsureArray(
JsonObject parent,
string propertyName,
ImmutableArray<string>.Builder warnings,
string context,
string fromVersion)
{
if (parent.TryGetPropertyValue(propertyName, out var node) && node is JsonArray array)
{
return array;
}
var created = new JsonArray();
parent[propertyName] = created;
warnings.Add($"Inserted empty '{context}.{propertyName}' array while upgrading from {fromVersion}.");
return created;
}
private static void NormalizePositiveInt(
JsonObject obj,
string propertyName,
ImmutableArray<string>.Builder warnings,
string context,
string fromVersion,
bool allowZero = false)
{
if (!obj.TryGetPropertyValue(propertyName, out var node))
{
return;
}
if (!TryReadInt(node, out var value))
{
obj.Remove(propertyName);
warnings.Add($"Removed invalid '{context}.{propertyName}' while upgrading from {fromVersion}.");
return;
}
if ((!allowZero && value <= 0) || (allowZero && value < 0))
{
obj.Remove(propertyName);
warnings.Add($"Removed non-positive '{context}.{propertyName}' value while upgrading from {fromVersion}.");
return;
}
obj[propertyName] = value;
}
private static void NormalizeNonNegativeInt(
JsonObject obj,
string propertyName,
ImmutableArray<string>.Builder warnings,
string context,
string fromVersion)
{
if (!obj.TryGetPropertyValue(propertyName, out var node) || !TryReadNonNegative(node, out var value))
{
obj[propertyName] = 0;
warnings.Add($"Defaulted '{context}.{propertyName}' to 0 while upgrading from {fromVersion}.");
return;
}
obj[propertyName] = value;
}
private static void NormalizeBoolean(
JsonObject obj,
string propertyName,
bool defaultValue,
ImmutableArray<string>.Builder warnings,
string context,
string fromVersion)
{
if (!obj.TryGetPropertyValue(propertyName, out var node))
{
obj[propertyName] = defaultValue;
warnings.Add($"Defaulted '{context}.{propertyName}' to {defaultValue.ToString().ToLowerInvariant()} while upgrading from {fromVersion}.");
return;
}
if (node is JsonValue value && value.TryGetValue(out bool parsed))
{
obj[propertyName] = parsed;
return;
}
if (node is JsonValue strValue && strValue.TryGetValue(out string? text) &&
bool.TryParse(text, out var parsedFromString))
{
obj[propertyName] = parsedFromString;
return;
}
obj[propertyName] = defaultValue;
warnings.Add($"Normalized '{context}.{propertyName}' to {defaultValue.ToString().ToLowerInvariant()} while upgrading from {fromVersion}.");
}
private static void NormalizeSeverity(
JsonObject obj,
string propertyName,
ImmutableArray<string>.Builder warnings,
string context,
string fromVersion)
{
if (!obj.TryGetPropertyValue(propertyName, out var node))
{
return;
}
if (node is JsonValue value)
{
if (value.TryGetValue(out string? text))
{
if (Enum.TryParse<SeverityRank>(text, ignoreCase: true, out var parsed))
{
obj[propertyName] = parsed.ToString().ToLowerInvariant();
return;
}
}
if (value.TryGetValue(out int numeric) && Enum.IsDefined(typeof(SeverityRank), numeric))
{
var enumValue = (SeverityRank)numeric;
obj[propertyName] = enumValue.ToString().ToLowerInvariant();
return;
}
}
obj.Remove(propertyName);
warnings.Add($"Removed invalid '{context}.{propertyName}' while upgrading from {fromVersion}.");
}
private static bool TryReadNonNegative(JsonNode? node, out int value)
=> TryReadInt(node, out value) && value >= 0;
private static bool TryReadInt(JsonNode? node, out int value)
{
if (node is JsonValue valueNode)
{
if (valueNode.TryGetValue(out int intValue))
{
value = intValue;
return true;
}
if (valueNode.TryGetValue(out long longValue) && longValue is >= int.MinValue and <= int.MaxValue)
{
value = (int)longValue;
return true;
}
if (valueNode.TryGetValue(out string? text) &&
int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
value = parsed;
return true;
}
}
value = 0;
return false;
}
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Immutable;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Result from upgrading a scheduler document to the latest schema version.
/// </summary>
/// <typeparam name="T">Target DTO type.</typeparam>
public sealed record SchedulerSchemaMigrationResult<T>(
T Value,
string FromVersion,
string ToVersion,
ImmutableArray<string> Warnings);

View File

@@ -0,0 +1,54 @@
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Canonical schema version identifiers for scheduler documents.
/// </summary>
public static class SchedulerSchemaVersions
{
public const string Schedule = "scheduler.schedule@1";
public const string Run = "scheduler.run@1";
public const string ImpactSet = "scheduler.impact-set@1";
public const string GraphBuildJob = "scheduler.graph-build-job@1";
public const string GraphOverlayJob = "scheduler.graph-overlay-job@1";
public const string PolicyRunRequest = "scheduler.policy-run-request@1";
public const string PolicyRunStatus = "scheduler.policy-run-status@1";
public const string PolicyDiffSummary = "scheduler.policy-diff-summary@1";
public const string PolicyExplainTrace = "scheduler.policy-explain-trace@1";
public const string PolicyRunJob = "scheduler.policy-run-job@1";
public const string ScheduleLegacy0 = "scheduler.schedule@0";
public const string RunLegacy0 = "scheduler.run@0";
public const string ImpactSetLegacy0 = "scheduler.impact-set@0";
public static string EnsureSchedule(string? value)
=> Normalize(value, Schedule);
public static string EnsureRun(string? value)
=> Normalize(value, Run);
public static string EnsureImpactSet(string? value)
=> Normalize(value, ImpactSet);
public static string EnsureGraphBuildJob(string? value)
=> Normalize(value, GraphBuildJob);
public static string EnsureGraphOverlayJob(string? value)
=> Normalize(value, GraphOverlayJob);
public static string EnsurePolicyRunRequest(string? value)
=> Normalize(value, PolicyRunRequest);
public static string EnsurePolicyRunStatus(string? value)
=> Normalize(value, PolicyRunStatus);
public static string EnsurePolicyDiffSummary(string? value)
=> Normalize(value, PolicyDiffSummary);
public static string EnsurePolicyExplainTrace(string? value)
=> Normalize(value, PolicyExplainTrace);
public static string EnsurePolicyRunJob(string? value)
=> Normalize(value, PolicyRunJob);
private static string Normalize(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}

View File

@@ -0,0 +1,134 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Selector filters used to resolve impacted assets.
/// </summary>
public sealed record Selector
{
public Selector(
SelectorScope scope,
string? tenantId = null,
IEnumerable<string>? namespaces = null,
IEnumerable<string>? repositories = null,
IEnumerable<string>? digests = null,
IEnumerable<string>? includeTags = null,
IEnumerable<LabelSelector>? labels = null,
bool resolvesTags = false)
: this(
scope,
tenantId,
Validation.NormalizeStringSet(namespaces, nameof(namespaces)),
Validation.NormalizeStringSet(repositories, nameof(repositories)),
Validation.NormalizeDigests(digests, nameof(digests)),
Validation.NormalizeTagPatterns(includeTags),
NormalizeLabels(labels),
resolvesTags)
{
}
[JsonConstructor]
public Selector(
SelectorScope scope,
string? tenantId,
ImmutableArray<string> namespaces,
ImmutableArray<string> repositories,
ImmutableArray<string> digests,
ImmutableArray<string> includeTags,
ImmutableArray<LabelSelector> labels,
bool resolvesTags)
{
Scope = scope;
TenantId = tenantId is null ? null : Validation.EnsureTenantId(tenantId, nameof(tenantId));
Namespaces = namespaces.IsDefault ? ImmutableArray<string>.Empty : namespaces;
Repositories = repositories.IsDefault ? ImmutableArray<string>.Empty : repositories;
Digests = digests.IsDefault ? ImmutableArray<string>.Empty : digests;
IncludeTags = includeTags.IsDefault ? ImmutableArray<string>.Empty : includeTags;
Labels = labels.IsDefault ? ImmutableArray<LabelSelector>.Empty : labels;
ResolvesTags = resolvesTags;
if (Scope is SelectorScope.ByDigest && Digests.Length == 0)
{
throw new ArgumentException("At least one digest is required when scope is by-digest.", nameof(digests));
}
if (Scope is SelectorScope.ByNamespace && Namespaces.Length == 0)
{
throw new ArgumentException("Namespaces are required when scope is by-namespace.", nameof(namespaces));
}
if (Scope is SelectorScope.ByRepository && Repositories.Length == 0)
{
throw new ArgumentException("Repositories are required when scope is by-repo.", nameof(repositories));
}
if (Scope is SelectorScope.ByLabels && Labels.Length == 0)
{
throw new ArgumentException("Labels are required when scope is by-labels.", nameof(labels));
}
}
public SelectorScope Scope { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TenantId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Namespaces { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Repositories { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Digests { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> IncludeTags { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<LabelSelector> Labels { get; } = ImmutableArray<LabelSelector>.Empty;
public bool ResolvesTags { get; }
private static ImmutableArray<LabelSelector> NormalizeLabels(IEnumerable<LabelSelector>? labels)
{
if (labels is null)
{
return ImmutableArray<LabelSelector>.Empty;
}
return labels
.Where(static label => label is not null)
.Select(static label => label!)
.OrderBy(static label => label.Key, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
/// Describes a label match (key and optional accepted values).
/// </summary>
public sealed record LabelSelector
{
public LabelSelector(string key, IEnumerable<string>? values = null)
: this(key, NormalizeValues(values))
{
}
[JsonConstructor]
public LabelSelector(string key, ImmutableArray<string> values)
{
Key = Validation.EnsureSimpleIdentifier(key, nameof(key));
Values = values.IsDefault ? ImmutableArray<string>.Empty : values;
}
public string Key { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Values { get; } = ImmutableArray<string>.Empty;
private static ImmutableArray<string> NormalizeValues(IEnumerable<string>? values)
=> Validation.NormalizeStringSet(values, nameof(values));
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,22 @@
# Scheduler Models Task Board (Sprint 16)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCHED-MODELS-16-101 | DONE (2025-10-19) | Scheduler Models Guild | — | Define DTOs (Schedule, Run, ImpactSet, Selector, DeltaSummary, AuditRecord) with validation + canonical JSON. | DTOs merged with tests; documentation snippet added; serialization deterministic. |
| SCHED-MODELS-16-102 | DONE (2025-10-19) | Scheduler Models Guild | SCHED-MODELS-16-101 | Publish schema docs & sample payloads for UI/Notify integration. | Samples committed; docs referenced; contract tests pass. |
| SCHED-MODELS-16-103 | DONE (2025-10-20) | Scheduler Models Guild | SCHED-MODELS-16-101 | Versioning/migration helpers (schedule evolution, run state transitions). | Migration helpers implemented; tests cover upgrade/downgrade; guidelines documented. |
## Policy Engine v2 (Sprint 20)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCHED-MODELS-20-001 | DONE (2025-10-26) | Scheduler Models Guild, Policy Guild | POLICY-ENGINE-20-000 | Define DTOs/schemas for policy runs, diffs, and explain traces (`PolicyRunRequest`, `PolicyRunStatus`, `PolicyDiffSummary`). | DTOs serialize deterministically; schema samples committed; validation helpers added. |
| SCHED-MODELS-20-002 | DONE (2025-10-29) | Scheduler Models Guild | SCHED-MODELS-20-001 | Extend scheduler schema docs to include policy run lifecycle, environment metadata, and diff payloads. | Docs updated with compliance checklist; samples validated against JSON schema; consumers notified. |
> 2025-10-29: Added lifecycle table, environment metadata section, and diff payload breakdown to `SCHED-MODELS-20-001-POLICY-RUNS.md`; compliance checklist extended to cover new documentation.
## Graph Explorer v1 (Sprint 21)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCHED-MODELS-21-001 | DONE (2025-10-26) | Scheduler Models Guild, Cartographer Guild | CARTO-GRAPH-21-007 | Define job DTOs for graph builds/overlay refresh (`GraphBuildJob`, `GraphOverlayJob`) with deterministic serialization and status enums. | DTOs serialized deterministically; schema snippets documented in `src/Scheduler/__Libraries/StellaOps.Scheduler.Models/docs/SCHED-MODELS-21-001-GRAPH-JOBS.md`; tests cover transitions. |
| SCHED-MODELS-21-002 | DONE (2025-10-26) | Scheduler Models Guild | SCHED-MODELS-21-001 | Publish schema docs/sample payloads for graph jobs and overlay events for downstream workers/UI. | Docs updated with compliance checklist; samples validated; notifications sent to guilds. |

View File

@@ -0,0 +1,247 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Lightweight validation helpers for scheduler DTO constructors.
/// </summary>
internal static partial class Validation
{
private const int MaxIdentifierLength = 256;
private const int MaxNameLength = 200;
public static string EnsureId(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
if (normalized.Length > MaxIdentifierLength)
{
throw new ArgumentException($"Value exceeds {MaxIdentifierLength} characters.", paramName);
}
return normalized;
}
public static string EnsureName(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
if (normalized.Length > MaxNameLength)
{
throw new ArgumentException($"Value exceeds {MaxNameLength} characters.", paramName);
}
return normalized;
}
public static string EnsureTenantId(string value, string paramName)
{
var normalized = EnsureId(value, paramName);
if (!TenantRegex().IsMatch(normalized))
{
throw new ArgumentException("Tenant id must be alphanumeric with '-', '_' separators.", paramName);
}
return normalized;
}
public static string EnsureCronExpression(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
if (normalized.Length > 128 || normalized.Contains('\n', StringComparison.Ordinal) || normalized.Contains('\r', StringComparison.Ordinal))
{
throw new ArgumentException("Cron expression too long or contains invalid characters.", paramName);
}
if (!CronSegmentRegex().IsMatch(normalized))
{
throw new ArgumentException("Cron expression contains unsupported characters.", paramName);
}
return normalized;
}
public static string EnsureTimezone(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
try
{
_ = TimeZoneInfo.FindSystemTimeZoneById(normalized);
}
catch (TimeZoneNotFoundException ex)
{
throw new ArgumentException($"Timezone '{normalized}' is not recognized on this host.", paramName, ex);
}
catch (InvalidTimeZoneException ex)
{
throw new ArgumentException($"Timezone '{normalized}' is invalid.", paramName, ex);
}
return normalized;
}
public static string? TrimToNull(string? value)
=> string.IsNullOrWhiteSpace(value)
? null
: value.Trim();
public static ImmutableArray<string> NormalizeStringSet(IEnumerable<string>? values, string paramName, bool allowWildcards = false)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var result = values
.Select(static value => TrimToNull(value))
.Where(static value => value is not null)
.Select(value => allowWildcards ? value! : EnsureSimpleIdentifier(value!, paramName))
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray();
return result;
}
public static ImmutableArray<string> NormalizeTagPatterns(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var result = values
.Select(static value => TrimToNull(value))
.Where(static value => value is not null)
.Select(static value => value!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return result;
}
public static ImmutableArray<string> NormalizeDigests(IEnumerable<string>? values, string paramName)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var result = values
.Select(static value => TrimToNull(value))
.Where(static value => value is not null)
.Select(value => EnsureDigestFormat(value!, paramName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return result;
}
public static int? EnsurePositiveOrNull(int? value, string paramName)
{
if (value is null)
{
return null;
}
if (value <= 0)
{
throw new ArgumentOutOfRangeException(paramName, value, "Value must be greater than zero.");
}
return value;
}
public static int EnsureNonNegative(int value, string paramName)
{
if (value < 0)
{
throw new ArgumentOutOfRangeException(paramName, value, "Value must be zero or greater.");
}
return value;
}
public static ImmutableSortedDictionary<string, string> NormalizeMetadata(IEnumerable<KeyValuePair<string, string>>? metadata)
{
if (metadata is null)
{
return ImmutableSortedDictionary<string, string>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
var key = TrimToNull(pair.Key);
var value = TrimToNull(pair.Value);
if (key is null || value is null)
{
continue;
}
var normalizedKey = key.ToLowerInvariant();
if (!builder.ContainsKey(normalizedKey))
{
builder[normalizedKey] = value;
}
}
return builder.ToImmutable();
}
public static string EnsureSimpleIdentifier(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
if (!SimpleIdentifierRegex().IsMatch(normalized))
{
throw new ArgumentException("Value must contain letters, digits, '-', '_', '.', or '/'.", paramName);
}
return normalized;
}
public static string EnsureDigestFormat(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName).ToLowerInvariant();
if (!normalized.StartsWith("sha256:", StringComparison.Ordinal) || normalized.Length <= 7)
{
throw new ArgumentException("Digest must start with 'sha256:' and contain a hex payload.", paramName);
}
if (!HexRegex().IsMatch(normalized.AsSpan(7)))
{
throw new ArgumentException("Digest must be hexadecimal.", paramName);
}
return normalized;
}
public static string EnsureNotNullOrWhiteSpace(string value, string paramName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
}
return value.Trim();
}
public static DateTimeOffset NormalizeTimestamp(DateTimeOffset value)
=> value.ToUniversalTime();
public static DateTimeOffset? NormalizeTimestamp(DateTimeOffset? value)
=> value?.ToUniversalTime();
[GeneratedRegex("^[A-Za-z0-9_-]+$")]
private static partial Regex TenantRegex();
[GeneratedRegex("^[A-Za-z0-9_./:@+\\-]+$")]
private static partial Regex SimpleIdentifierRegex();
[GeneratedRegex("^[A-Za-z0-9:*?/_.,\\- ]+$")]
private static partial Regex CronSegmentRegex();
[GeneratedRegex("^[a-f0-9]+$", RegexOptions.IgnoreCase)]
private static partial Regex HexRegex();
}

View File

@@ -0,0 +1,86 @@
# SCHED-MODELS-16-103 — Scheduler Schema Versioning & Run State Helpers
## Goals
- Track schema revisions for `Schedule` and `Run` documents so storage upgrades are deterministic across air-gapped installs.
- Provide reusable upgrade helpers that normalize Mongo snapshots (raw BSON → JSON) into the latest DTOs without mutating inputs.
- Formalize the allowed `RunState` graph and surface guard-rail helpers (timestamps, stats monotonicity) for planners/runners.
## Non-goals
- Implementing the helpers (covered by the main task).
- Downgrading documents to legacy schema revisions (can be added if Offline Kit requires it).
- Persisted data backfills or data migration jobs; we focus on in-process upgrades during read.
## Schema Version Strategy
- Introduce `SchedulerSchemaVersions` constants:
- `scheduler.schedule@1` (base record with subscribers, limits burst default).
- `scheduler.run@1` (run metadata + delta summaries).
- `scheduler.impact-set@1` (shared envelope used by planners).
- Expose `EnsureSchedule`, `EnsureRun`, `EnsureImpactSet` helpers mirroring the Notify model pattern to normalize missing/whitespace values.
- Extend `Schedule`, `Run`, and `ImpactSet` records with an optional `schemaVersion` constructor parameter defaulting through the `Ensure*` helpers. The canonical JSON serializer will list `schemaVersion` first so documents round-trip deterministically.
- Persisted Mongo documents will now always include `schemaVersion`; exporters/backups can rely on this when bundling Offline Kit snapshots.
## Migration Helper Shape
- Add `SchedulerSchemaMigration` static class with:
- `Schedule UpgradeSchedule(JsonNode document)`
- `Run UpgradeRun(JsonNode document)`
- `ImpactSet UpgradeImpactSet(JsonNode document)`
- Each method clones the incoming node, normalizes `schemaVersion` (injecting default if missing), then applies an upgrade pipeline:
1. `Normalize` — ensure object, strip unknown members when `strict` flag is set, coerce enums via converters.
2. `ApplyLegacyFixups` — version-specific patches, e.g., backfill `subscribers`, migrate `limits.burst`, convert legacy trigger strings.
3. `Deserialize` — use `CanonicalJsonSerializer.Deserialize<T>` so property order/enum parsing stays centralized.
- Expose `SchedulerSchemaMigrationResult<T>` record returning `(T Value, string FromVersion, string ToVersion, ImmutableArray<string> Warnings)` to surface non-blocking issues to callers (web service, worker, storage).
- Helpers remain dependency-free so storage/web modules can reference them without circular dependencies.
## Schedule Evolution Considerations
- **@1** fields: `mode`, `selection`, `onlyIf`, `notify`, `limits` (incl. `burst` default 0), `subscribers` (sorted unique), audit metadata.
- Future **@2** candidate changes to plan for in helpers:
- `limits`: splitting `parallelism` into planner/runner concurrency.
- `selection`: adding `impactWindow` semantics.
- `notify`: optional per-channel overrides.
- Upgrade pipeline will carry forward unknown fields in a `JsonNode` bag so future versions can opt-in to strict dropping while maintaining backwards compatibility for current release.
## Run State Transition Helper
- Introduce `RunStateMachine` (static) encapsulating allowed transitions and invariants.
- Define adjacency map:
- `Planning → {Queued, Cancelled}`
- `Queued → {Running, Cancelled}`
- `Running → {Completed, Error, Cancelled}`
- `Completed`, `Error`, `Cancelled` are terminal.
- Provide `bool CanTransition(RunState from, RunState to)` and `Run EnsureTransition(Run run, RunState next, DateTimeOffset now, Action<RunStatsBuilder>? mutateStats = null)`.
- `EnsureTransition` performs:
- Timestamp enforcement: `StartedAt` auto-populated on first entry into `Running`; `FinishedAt` set when entering any terminal state; ensures monotonic ordering (`CreatedAt ≤ StartedAt ≤ FinishedAt`).
- Stats guardrails: cumulative counters must not decrease; `RunStatsBuilder` wrapper ensures atomic updates.
- Error context: require `error` message when transitioning to `Error`; clear error for non-error entries.
- Provide `Validate(Run run)` to check invariants for documents loaded from storage before use (e.g., stale snapshots).
- Expose small helper to tag `RunReason.ImpactWindowFrom/To` automatically when set by planners (using normalized ISO-8601).
## Interaction Points
- **WebService**: call `SchedulerSchemaMigration.UpgradeSchedule` when returning schedules from Mongo, so clients always see the newest DTO regardless of stored version.
- **Storage.Mongo**: wrap DTO round-trips; the migration helper acts during read, and the state machine ensures updates respect transition rules before writing.
- **Queue/Worker**: use `RunStateMachine.EnsureTransition` to guard planner/runner state updates (replace ad-hoc `with run` clones).
- **Offline Kit**: embed `schemaVersion` in exported JSON/Trivy artifacts; migrations ensure air-gapped upgrades flow without manual scripts.
## Implementation Steps (for follow-up task)
1. Add `SchedulerSchemaVersions` + update DTO constructors/properties.
2. Implement `SchedulerSchemaMigration` helpers and shared `MigrationResult` envelope.
3. Introduce `RunStateMachine` with invariants + supporting `RunStatsBuilder`.
4. Update modules (Storage, WebService, Worker) to use new helpers; add logging around migrations/transitions.
## Test Strategy
- **Migration happy-path**: load sample Mongo fixtures for `schedule@1` and `run@1`, assert `schemaVersion` normalization, deduplicated subscribers, limits defaults. Include snapshots without the version field to exercise defaulting logic.
- **Legacy upgrade cases**: craft synthetic `schedule@0` / `run@0` JSON fragments (missing new fields, using old enum names) and verify version-specific fixups produce the latest DTO while populating `MigrationResult.Warnings`.
- **Strict mode behavior**: attempt to upgrade documents with unexpected properties and ensure warnings/throws align with configuration.
- **Run state transitions**: unit-test `RunStateMachine` for every allowed edge, invalid transitions, and timestamp/error invariants (e.g., `FinishedAt` only set on terminal states). Provide parameterized tests to confirm stats monotonicity enforcement.
- **Serialization determinism**: round-trip upgraded DTOs via `CanonicalJsonSerializer` to confirm property order includes `schemaVersion` first and produces stable hashes.
- **Documentation snippets**: extend module README or API docs with example migrations/run-state usage; verify via doc samples test (if available) or include as part of CI doc linting.
## Open Questions
- Do we need downgrade (`ToVersion`) helpers for Offline Kit exports? (Assumed no for now. Add backlog item if required.)
- Should `ImpactSet` migrations live here or in ImpactIndex module? (Lean towards here because DTO defined in Models; coordinate with ImpactIndex guild if they need specialized upgrades.)
- How do we surface migration warnings to telemetry? Proposal: caller logs `warning` with `MigrationResult.Warnings` immediately after calling helper.
## Status — 2025-10-20
- `SchedulerSchemaMigration` now upgrades legacy `@0` schedule/run/impact-set documents to the `@1` schema, defaulting missing counters/arrays and normalizing booleans & severities. Each backfill emits a warning so storage/web callers can log the mutation.
- `RunStateMachine.EnsureTransition` guards timestamp ordering and stats monotonicity; builders and extension helpers are wired into the scheduler worker/web service plans.
- Tests exercising legacy upgrades live in `StellaOps.Scheduler.Models.Tests/SchedulerSchemaMigrationTests.cs`; add new fixtures there when introducing additional schema versions.

View File

@@ -0,0 +1,148 @@
# SCHED-MODELS-20-001 — Policy Engine Run DTOs
> Status: 2025-10-26 — **Complete**
Defines the scheduler contracts that Policy Engine (Epic 2) relies on for orchestration, simulation, and explainability. DTOs serialize with `CanonicalJsonSerializer` to guarantee deterministic ordering, enabling replay and signed artefacts.
## PolicyRunRequest — `scheduler.policy-run-request@1`
Posted by CLI/UI or the orchestrator to enqueue a run. Canonical sample lives at `samples/api/scheduler/policy-run-request.json`.
```jsonc
{
"schemaVersion": "scheduler.policy-run-request@1",
"tenantId": "default",
"policyId": "P-7",
"policyVersion": 4,
"mode": "incremental", // full | incremental | simulate
"priority": "normal", // normal | high | emergency
"runId": "run:P-7:2025-10-26:auto", // optional idempotency key
"queuedAt": "2025-10-26T14:05:00+00:00",
"requestedBy": "user:cli",
"correlationId": "req-...",
"metadata": {"source": "stella policy run", "trigger": "cli"},
"inputs": {
"sbomSet": ["sbom:S-318", "sbom:S-42"], // sorted uniques
"advisoryCursor": "2025-10-26T13:59:00+00:00",
"vexCursor": "2025-10-26T13:58:30+00:00",
"environment": {"exposure": "internet", "sealed": false},
"captureExplain": true
}
}
```
* Environment values accept any JSON primitive/object; keys normalise to lowercase for deterministic hashing.
* `metadata` is optional contextual breadcrumbs (lowercased keys). Use it for orchestrator provenance or offline bundle identifiers.
## PolicyRunStatus — `scheduler.policy-run-status@1`
Captured in `policy_runs` collection and returned by run status APIs. Sample: `samples/api/scheduler/policy-run-status.json`.
```jsonc
{
"schemaVersion": "scheduler.policy-run-status@1",
"runId": "run:P-7:2025-10-26:auto",
"tenantId": "default",
"policyId": "P-7",
"policyVersion": 4,
"mode": "incremental",
"status": "succeeded", // queued|running|succeeded|failed|canceled|replay_pending
"priority": "normal",
"queuedAt": "2025-10-26T14:05:00+00:00",
"startedAt": "2025-10-26T14:05:11+00:00",
"finishedAt": "2025-10-26T14:06:01+00:00",
"determinismHash": "sha256:...", // optional until run completes
"traceId": "01HE0BJX5S4T9YCN6ZT0",
"metadata": {"orchestrator": "scheduler", "sbombatchhash": "sha256:..."},
"stats": {
"components": 1742,
"rulesFired": 68023,
"findingsWritten": 4321,
"vexOverrides": 210,
"quieted": 12,
"durationSeconds": 50.8
},
"inputs": { ... } // same schema as request
}
```
* `determinismHash` must be a `sha256:` digest combining ordered input digests + policy digest.
* `attempts` (not shown) increases per retry.
* Error responses populate `errorCode` (`ERR_POL_00x`) and `error` message; omitted when successful.
## PolicyDiffSummary — `scheduler.policy-diff-summary@1`
Returned by simulation APIs; referenced by CLI/UI diff visualisations. Sample: `samples/api/scheduler/policy-diff-summary.json`.
```jsonc
{
"schemaVersion": "scheduler.policy-diff-summary@1",
"added": 12,
"removed": 8,
"unchanged": 657,
"bySeverity": {
"critical": {"up": 1},
"high": {"up": 3, "down": 4},
"medium": {"up": 2, "down": 1}
},
"ruleHits": [
{"ruleId": "rule-block-critical", "ruleName": "Block Critical Findings", "up": 1},
{"ruleId": "rule-quiet-low", "ruleName": "Quiet Low Risk", "down": 2}
]
}
```
* Severity bucket keys normalise to camelCase for JSON determinism across CLI/UI consumers.
* Zero-valued counts (`down`/`up`) are omitted to keep payloads compact.
* `ruleHits` sorts by `ruleId` to keep diff heatmaps deterministic.
## PolicyExplainTrace — `scheduler.policy-explain-trace@1`
Canonical explain tree embedded in findings explainers and exported bundles. Sample: `samples/api/scheduler/policy-explain-trace.json`.
```jsonc
{
"schemaVersion": "scheduler.policy-explain-trace@1",
"findingId": "finding:sbom:S-42/pkg:npm/lodash@4.17.21",
"policyId": "P-7",
"policyVersion": 4,
"tenantId": "default",
"runId": "run:P-7:2025-10-26:auto",
"evaluatedAt": "2025-10-26T14:06:01+00:00",
"verdict": {"status": "blocked", "severity": "critical", "score": 19.5},
"ruleChain": [
{"ruleId": "rule-allow-known", "action": "allow", "decision": "skipped"},
{"ruleId": "rule-block-critical", "action": "block", "decision": "matched", "score": 19.5}
],
"evidence": [
{"type": "advisory", "reference": "CVE-2025-12345", "source": "nvd", "status": "affected", "weight": 1, "metadata": {}},
{"type": "vex", "reference": "vex:ghsa-2025-0001", "source": "vendor", "status": "not_affected", "weight": 0.5}
],
"vexImpacts": [
{"statementId": "vex:ghsa-2025-0001", "provider": "vendor", "status": "not_affected", "accepted": true}
],
"history": [
{"status": "blocked", "occurredAt": "2025-10-26T14:06:01+00:00", "actor": "policy-engine"}
],
"metadata": {"componentpurl": "pkg:npm/lodash@4.17.21", "sbomid": "sbom:S-42", "traceid": "01HE0BJX5S4T9YCN6ZT0"}
}
```
* Rule chain preserves execution order; evidence & VEX arrays sort for deterministic outputs.
* Evidence metadata is always emitted (empty object when no attributes) so clients can merge annotations deterministically.
* Metadata keys lower-case for consistent lookups (`componentpurl`, `traceid`, etc.).
* `verdict.status` uses `passed|warned|blocked|quieted|ignored` reflecting final policy decision.
## Compliance Checklist
| Item | Owner | Status | Notes |
| --- | --- | --- | --- |
| Canonical samples committed (`policy-run-request|status|diff-summary|explain-trace`) | Scheduler Models Guild | ☑ 2025-10-26 | Round-trip tests enforce schema stability. |
| DTOs documented here and linked from `/docs/policy/runs.md` checklist | Scheduler Models Guild | ☑ 2025-10-26 | Added Run DTO schema section. |
| Serializer ensures deterministic ordering for new types | Scheduler Models Guild | ☑ 2025-10-26 | `CanonicalJsonSerializer` updated with property order + converters. |
| Tests cover DTO validation and sample fixtures | Scheduler Models Guild | ☑ 2025-10-26 | `PolicyRunModelsTests` + extended `SamplePayloadTests`. |
| Scheduler guilds notified (Models, Worker, WebService) | Scheduler Models Guild | ☑ 2025-10-26 | Posted in `#scheduler-guild` with sample links. |
---
*Last updated: 2025-10-26.*

View File

@@ -0,0 +1,107 @@
# SCHED-MODELS-21-001 — Graph Job DTOs
> Status: 2025-10-26 — **Complete**
Defines the scheduler-facing contracts for Cartographer orchestration. Both DTOs serialize with `CanonicalJsonSerializer` and share the `GraphJobStatus` lifecycle guarded by `GraphJobStateMachine`.
## GraphBuildJob — `scheduler.graph-build-job@1`
```jsonc
{
"schemaVersion": "scheduler.graph-build-job@1",
"id": "gbj_...",
"tenantId": "tenant-id",
"sbomId": "sbom-id",
"sbomVersionId": "sbom-version-id",
"sbomDigest": "sha256:<64-hex>",
"graphSnapshotId": "graph-snapshot-id?", // optional until Cartographer returns id
"status": "pending|queued|running|completed|failed|cancelled",
"trigger": "sbom-version|backfill|manual",
"attempts": 0,
"cartographerJobId": "external-id?", // optional identifier returned by Cartographer
"correlationId": "evt-...", // optional event correlation key
"createdAt": "2025-10-26T12:00:00+00:00",
"startedAt": "2025-10-26T12:00:05+00:00?",
"completedAt": "2025-10-26T12:00:35+00:00?",
"error": "cartographer timeout?", // populated only for failed state
"metadata": { // extra provenance (sorted, case-insensitive keys)
"sbomEventId": "sbom_evt_123"
}
}
```
* `sbomDigest` must be a lowercase `sha256:<hex>` string.
* `attempts` is monotonic across retries; `GraphJobStateMachine.EnsureTransition` enforces non-decreasing values and timestamps.
* Terminal states (`completed|failed|cancelled`) require `completedAt` to be set; failures require `error`.
## GraphOverlayJob — `scheduler.graph-overlay-job@1`
```jsonc
{
"schemaVersion": "scheduler.graph-overlay-job@1",
"id": "goj_...",
"tenantId": "tenant-id",
"graphSnapshotId": "graph-snapshot-id",
"buildJobId": "gbj_...?",
"overlayKind": "policy|advisory|vex",
"overlayKey": "policy@2025-10-01",
"subjects": [
"artifact/service-api",
"artifact/service-worker"
],
"status": "pending|queued|running|completed|failed|cancelled",
"trigger": "policy|advisory|vex|sbom-version|manual",
"attempts": 0,
"correlationId": "policy_run_321?",
"createdAt": "2025-10-26T12:05:00+00:00",
"startedAt": "2025-10-26T12:05:05+00:00?",
"completedAt": "2025-10-26T12:05:15+00:00?",
"error": "overlay build failed?",
"metadata": {
"policyRunId": "policy_run_321"
}
}
```
* `overlayKey` is free-form but trimmed; `subjects` are deduplicated and lexicographically ordered.
* `GraphOverlayJobTrigger` strings (`policy`, `advisory`, `vex`, `sbom-version`, `manual`) align with upstream events (Policy Engine, Conseiller, Excititor, SBOM Service, or manual enqueue).
* State invariants mirror build jobs: timestamps advance monotonically, terminal states require `completedAt`, failures require `error`.
## Status & trigger matrix
| Enum | JSON values |
| --- | --- |
| `GraphJobStatus` | `pending`, `queued`, `running`, `completed`, `failed`, `cancelled` |
| `GraphBuildJobTrigger` | `sbom-version`, `backfill`, `manual` |
| `GraphOverlayJobTrigger` | `policy`, `advisory`, `vex`, `sbom-version`, `manual` |
| `GraphOverlayKind` | `policy`, `advisory`, `vex` |
`GraphJobStateMachine` exposes `CanTransition` and `EnsureTransition(...)` helpers to keep scheduler workers deterministic and to centralize validation logic. Callers must provide an error message when moving to `failed`; other states clear the error automatically.
---
## Published samples
- `samples/api/scheduler/graph-build-job.json` canonical Cartographer build request snapshot (status `running`, one retry).
- `samples/api/scheduler/graph-overlay-job.json` queued policy overlay job with deduplicated `subjects`.
- `docs/events/samples/scheduler.graph.job.completed@1.sample.json` legacy completion event embedding the canonical job payload for downstream caches/UI.
Tests in `StellaOps.Scheduler.Models.Tests/SamplePayloadTests.cs` validate the job fixtures against the canonical serializer.
---
## Events
Scheduler emits `scheduler.graph.job.completed@1` when a graph build or overlay job reaches `completed`, `failed`, or `cancelled`. Schema lives at `docs/events/scheduler.graph.job.completed@1.json` (legacy envelope) and the sample above illustrates the canonical payload. Downstream services should validate their consumers against the schema and budget for eventual migration to the orchestrator envelope once Cartographer hooks are promoted.
---
## Compliance checklist
| Item | Owner | Status | Notes |
| --- | --- | --- | --- |
| Canonical graph job samples committed under `samples/api/scheduler` | Scheduler Models Guild | ☑ 2025-10-26 | Round-trip tests cover both payloads. |
| Schema doc published with trigger/status matrix and sample references | Scheduler Models Guild | ☑ 2025-10-26 | This document. |
| Event schema + sample published under `docs/events/` | Scheduler Models Guild | ☑ 2025-10-26 | `scheduler.graph.job.completed@1` covers terminal job events. |
| Notify Scheduler WebService & Worker guilds about new DTO availability | Scheduler Models Guild | ☑ 2025-10-26 | Announcement posted (see `docs/updates/2025-10-26-scheduler-graph-jobs.md`). |
| Notify Cartographer Guild about expected job metadata (`graphSnapshotId`, `cartographerJobId`) | Scheduler Models Guild | ☑ 2025-10-26 | Included in Cartographer sync note (`docs/updates/2025-10-26-scheduler-graph-jobs.md`). |