up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-24 07:52:25 +02:00
parent 5970f0d9bd
commit 150b3730ef
215 changed files with 8119 additions and 740 deletions

View File

@@ -1,6 +1,6 @@
# API Changelog
Generated: 2025-11-19T07:40:32.086Z
Generated: 2025-11-24T01:50:48.086Z
## Additive Operations
- GET /export-center/bundles/{bundleId}/manifest

View File

@@ -1236,8 +1236,78 @@ internal static class CommandFactory
cancellationToken);
});
var explainOptions = CreateAdvisoryOptions();
var explain = new Command("explain", "Explain an advisory conflict set with narrative and rationale.");
AddAdvisoryOptions(explain, explainOptions);
explain.SetAction((parseResult, _) =>
{
var advisoryKey = parseResult.GetValue(explainOptions.AdvisoryKey) ?? string.Empty;
var artifactId = parseResult.GetValue(explainOptions.ArtifactId);
var artifactPurl = parseResult.GetValue(explainOptions.ArtifactPurl);
var policyVersion = parseResult.GetValue(explainOptions.PolicyVersion);
var profile = parseResult.GetValue(explainOptions.Profile) ?? "default";
var sections = parseResult.GetValue(explainOptions.Sections) ?? Array.Empty<string>();
var forceRefresh = parseResult.GetValue(explainOptions.ForceRefresh);
var timeoutSeconds = parseResult.GetValue(explainOptions.TimeoutSeconds) ?? 120;
var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(explainOptions.Format));
var outputPath = parseResult.GetValue(explainOptions.Output);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAdviseRunAsync(
services,
AdvisoryAiTaskType.Conflict,
advisoryKey,
artifactId,
artifactPurl,
policyVersion,
profile,
sections,
forceRefresh,
timeoutSeconds,
outputFormat,
outputPath,
verbose,
cancellationToken);
});
var remediateOptions = CreateAdvisoryOptions();
var remediate = new Command("remediate", "Generate remediation guidance for an advisory.");
AddAdvisoryOptions(remediate, remediateOptions);
remediate.SetAction((parseResult, _) =>
{
var advisoryKey = parseResult.GetValue(remediateOptions.AdvisoryKey) ?? string.Empty;
var artifactId = parseResult.GetValue(remediateOptions.ArtifactId);
var artifactPurl = parseResult.GetValue(remediateOptions.ArtifactPurl);
var policyVersion = parseResult.GetValue(remediateOptions.PolicyVersion);
var profile = parseResult.GetValue(remediateOptions.Profile) ?? "default";
var sections = parseResult.GetValue(remediateOptions.Sections) ?? Array.Empty<string>();
var forceRefresh = parseResult.GetValue(remediateOptions.ForceRefresh);
var timeoutSeconds = parseResult.GetValue(remediateOptions.TimeoutSeconds) ?? 120;
var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(remediateOptions.Format));
var outputPath = parseResult.GetValue(remediateOptions.Output);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAdviseRunAsync(
services,
AdvisoryAiTaskType.Remediation,
advisoryKey,
artifactId,
artifactPurl,
policyVersion,
profile,
sections,
forceRefresh,
timeoutSeconds,
outputFormat,
outputPath,
verbose,
cancellationToken);
});
advise.Add(run);
advise.Add(summarize);
advise.Add(explain);
advise.Add(remediate);
return advise;
}

View File

@@ -553,6 +553,12 @@ internal static class CommandHandlers
logger.LogInformation("Advisory output written to {Path}.", fullPath);
}
if (rendered is not null)
{
// Surface the rendered advisory to the active console so users (and tests) can see it even when also writing to disk.
AnsiConsole.Console.WriteLine(rendered);
}
if (output.Guardrail.Blocked)
{
logger.LogError("Guardrail blocked advisory output (cache key {CacheKey}).", output.CacheKey);
@@ -3075,7 +3081,7 @@ internal static class CommandHandlers
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
Console.WriteLine(json);
AnsiConsole.Console.WriteLine(json);
}
else
{
@@ -6359,9 +6365,9 @@ internal static class CommandHandlers
builder.AppendLine($"# Advisory {output.TaskType} ({output.Profile})");
builder.AppendLine();
builder.AppendLine($"- Cache Key: `{output.CacheKey}`");
builder.AppendLine($"- Generated: {output.GeneratedAtUtc.ToString(\"O\", CultureInfo.InvariantCulture)}");
builder.AppendLine($"- Plan From Cache: {(output.PlanFromCache ? \"yes\" : \"no\")}");
builder.AppendLine($"- Guardrail Blocked: {(output.Guardrail.Blocked ? \"yes\" : \"no\")}");
builder.AppendLine($"- Generated: {output.GeneratedAtUtc.ToString("O", CultureInfo.InvariantCulture)}");
builder.AppendLine($"- Plan From Cache: {(output.PlanFromCache ? "yes" : "no")}");
builder.AppendLine($"- Guardrail Blocked: {(output.Guardrail.Blocked ? "yes" : "no")}");
builder.AppendLine();
if (!string.IsNullOrWhiteSpace(output.Response))

View File

@@ -3,4 +3,6 @@
| Task ID | State | Notes |
| --- | --- | --- |
| `SCANNER-CLI-0001` | DONE (2025-11-12) | Ruby verbs now consume the persisted `RubyPackageInventory`, warn when inventories are missing, and docs/tests were refreshed per Sprint 138. |
| `CLI-AIAI-31-001` | BLOCKED (2025-11-22) | `stella advise summarize` command implemented; blocked on upstream Scanner analyzers (Node/Java) compile failures preventing CLI test run. |
| `CLI-AIAI-31-001` | DONE (2025-11-24) | `stella advise summarize` command implemented; CLI analyzer build & tests now pass locally. |
| `CLI-AIAI-31-002` | DONE (2025-11-24) | `stella advise explain` (conflict narrative) command implemented and tested. |
| `CLI-AIAI-31-003` | DONE (2025-11-24) | `stella advise remediate` command implemented and tested. |

View File

@@ -877,6 +877,202 @@ public sealed class CommandHandlersTests
}
}
[Fact]
public async Task HandleAdviseRunAsync_WritesMarkdownWithCitations_ForExplain()
{
var originalExit = Environment.ExitCode;
var originalConsole = AnsiConsole.Console;
var testConsole = new TestConsole();
try
{
Environment.ExitCode = 0;
AnsiConsole.Console = testConsole;
var planResponse = new AdvisoryPipelinePlanResponseModel
{
TaskType = "Conflict",
CacheKey = "plan-conflict",
PromptTemplate = "prompts/advisory/conflict.liquid",
Budget = new AdvisoryTaskBudgetModel
{
PromptTokens = 128,
CompletionTokens = 64
},
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
Metadata = new Dictionary<string, string>()
};
var outputResponse = new AdvisoryPipelineOutputModel
{
CacheKey = planResponse.CacheKey,
TaskType = planResponse.TaskType,
Profile = "default",
Prompt = "Sanitized prompt",
Response = "Rendered conflict body.",
Citations = new[]
{
new AdvisoryOutputCitationModel { Index = 1, DocumentId = "doc-42", ChunkId = "chunk-42" }
},
Metadata = new Dictionary<string, string>(),
Guardrail = new AdvisoryOutputGuardrailModel
{
Blocked = false,
SanitizedPrompt = "Sanitized prompt",
Violations = Array.Empty<AdvisoryOutputGuardrailViolationModel>(),
Metadata = new Dictionary<string, string>()
},
Provenance = new AdvisoryOutputProvenanceModel
{
InputDigest = "sha256:conflict-in",
OutputHash = "sha256:conflict-out",
Signatures = Array.Empty<string>()
},
GeneratedAtUtc = DateTimeOffset.Parse("2025-11-06T12:00:00Z", CultureInfo.InvariantCulture),
PlanFromCache = false
};
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
AdvisoryPlanResponse = planResponse,
AdvisoryOutputResponse = outputResponse
};
var provider = BuildServiceProvider(backend);
var outputPath = Path.GetTempFileName();
await CommandHandlers.HandleAdviseRunAsync(
provider,
AdvisoryAiTaskType.Conflict,
"ADV-42",
null,
null,
null,
"default",
Array.Empty<string>(),
forceRefresh: false,
timeoutSeconds: 0,
outputFormat: AdvisoryOutputFormat.Markdown,
outputPath: outputPath,
verbose: false,
cancellationToken: CancellationToken.None);
var markdown = await File.ReadAllTextAsync(outputPath);
Assert.Contains("Conflict", markdown, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Rendered conflict body", markdown, StringComparison.OrdinalIgnoreCase);
Assert.Contains("doc-42", markdown, StringComparison.OrdinalIgnoreCase);
Assert.Contains("chunk-42", markdown, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Citations", markdown, StringComparison.OrdinalIgnoreCase);
Assert.Equal(0, Environment.ExitCode);
Assert.Contains("Conflict", testConsole.Output, StringComparison.OrdinalIgnoreCase);
Assert.Equal(AdvisoryAiTaskType.Conflict, backend.AdvisoryPlanRequests.Last().TaskType);
}
finally
{
AnsiConsole.Console = originalConsole;
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleAdviseRunAsync_WritesMarkdownWithCitations_ForRemediation()
{
var originalExit = Environment.ExitCode;
var originalConsole = AnsiConsole.Console;
var testConsole = new TestConsole();
try
{
Environment.ExitCode = 0;
AnsiConsole.Console = testConsole;
var planResponse = new AdvisoryPipelinePlanResponseModel
{
TaskType = "Remediation",
CacheKey = "plan-remediation",
PromptTemplate = "prompts/advisory/remediation.liquid",
Budget = new AdvisoryTaskBudgetModel
{
PromptTokens = 192,
CompletionTokens = 96
},
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
Metadata = new Dictionary<string, string>()
};
var outputResponse = new AdvisoryPipelineOutputModel
{
CacheKey = planResponse.CacheKey,
TaskType = planResponse.TaskType,
Profile = "default",
Prompt = "Sanitized prompt",
Response = "Rendered remediation body.",
Citations = new[]
{
new AdvisoryOutputCitationModel { Index = 1, DocumentId = "doc-77", ChunkId = "chunk-77" }
},
Metadata = new Dictionary<string, string>(),
Guardrail = new AdvisoryOutputGuardrailModel
{
Blocked = false,
SanitizedPrompt = "Sanitized prompt",
Violations = Array.Empty<AdvisoryOutputGuardrailViolationModel>(),
Metadata = new Dictionary<string, string>()
},
Provenance = new AdvisoryOutputProvenanceModel
{
InputDigest = "sha256:remediation-in",
OutputHash = "sha256:remediation-out",
Signatures = Array.Empty<string>()
},
GeneratedAtUtc = DateTimeOffset.Parse("2025-11-06T12:00:00Z", CultureInfo.InvariantCulture),
PlanFromCache = false
};
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
AdvisoryPlanResponse = planResponse,
AdvisoryOutputResponse = outputResponse
};
var provider = BuildServiceProvider(backend);
var outputPath = Path.GetTempFileName();
await CommandHandlers.HandleAdviseRunAsync(
provider,
AdvisoryAiTaskType.Remediation,
"ADV-77",
null,
null,
null,
"default",
Array.Empty<string>(),
forceRefresh: false,
timeoutSeconds: 0,
outputFormat: AdvisoryOutputFormat.Markdown,
outputPath: outputPath,
verbose: false,
cancellationToken: CancellationToken.None);
var markdown = await File.ReadAllTextAsync(outputPath);
Assert.Contains("Remediation", markdown, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Rendered remediation body", markdown, StringComparison.OrdinalIgnoreCase);
Assert.Contains("doc-77", markdown, StringComparison.OrdinalIgnoreCase);
Assert.Contains("chunk-77", markdown, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Citations", markdown, StringComparison.OrdinalIgnoreCase);
Assert.Equal(0, Environment.ExitCode);
Assert.Contains("Remediation", testConsole.Output, StringComparison.OrdinalIgnoreCase);
Assert.Equal(AdvisoryAiTaskType.Remediation, backend.AdvisoryPlanRequests.Last().TaskType);
}
finally
{
AnsiConsole.Console = originalConsole;
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleAdviseRunAsync_ReturnsGuardrailExitCodeOnBlock()
{
@@ -3776,6 +3972,7 @@ spec:
Array.Empty<TaskRunnerSimulationOutput>(),
false);
public Exception? TaskRunnerSimulationException { get; set; }
public OfflineKitStatus? OfflineStatus { get; set; }
public PolicyActivationResult ActivationResult { get; set; } = new PolicyActivationResult(
"activated",
new PolicyActivationRevision(
@@ -3966,7 +4163,19 @@ spec:
=> throw new NotSupportedException();
public Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken)
=> throw new NotSupportedException();
{
return Task.FromResult(OfflineStatus ?? new OfflineKitStatus(
null,
null,
null,
false,
null,
null,
null,
null,
null,
Array.Empty<OfflineKitComponentStatus>()));
}
public Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken)
{

View File

@@ -32,6 +32,7 @@ public sealed record LnmLinksetPage(
public sealed record LnmLinksetNormalized(
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases,
[property: JsonPropertyName("purl")] IReadOnlyList<string>? Purl,
[property: JsonPropertyName("cpe")] IReadOnlyList<string>? Cpe,
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions,
[property: JsonPropertyName("ranges")] IReadOnlyList<object>? Ranges,
[property: JsonPropertyName("severities")] IReadOnlyList<object>? Severities);

View File

@@ -1752,6 +1752,9 @@ LnmLinksetResponse ToLnmResponse(
bool includeObservations)
{
var normalized = linkset.Normalized;
var severity = normalized?.Severities?.FirstOrDefault() is { } severityDict
? ExtractSeverity(severityDict)
: null;
var conflicts = includeConflicts
? (linkset.Conflicts ?? Array.Empty<AdvisoryLinksetConflict>()).Select(c =>
new LnmLinksetConflict(
@@ -1764,7 +1767,13 @@ LnmLinksetResponse ToLnmResponse(
: Array.Empty<LnmLinksetConflict>();
var timeline = includeTimeline
? Array.Empty<LnmLinksetTimeline>() // timeline not yet captured in linkset store
? new[]
{
new LnmLinksetTimeline(
Event: "created",
At: linkset.CreatedAt,
EvidenceHash: linkset.Provenance?.ObservationHashes?.FirstOrDefault())
}
: Array.Empty<LnmLinksetTimeline>();
var provenance = linkset.Provenance is null
@@ -1780,6 +1789,7 @@ LnmLinksetResponse ToLnmResponse(
: new LnmLinksetNormalized(
Aliases: null,
Purl: normalized.Purls,
Cpe: normalized.Cpes,
Versions: normalized.Versions,
Ranges: normalized.Ranges?.Select(r => (object)r).ToArray(),
Severities: normalized.Severities?.Select(s => (object)s).ToArray());
@@ -1788,11 +1798,11 @@ LnmLinksetResponse ToLnmResponse(
linkset.AdvisoryId,
linkset.Source,
normalized?.Purls ?? Array.Empty<string>(),
Array.Empty<string>(),
normalized?.Cpes ?? Array.Empty<string>(),
Summary: null,
PublishedAt: linkset.CreatedAt,
ModifiedAt: linkset.CreatedAt,
Severity: null,
Severity: severity,
Status: "fact-only",
provenance,
conflicts,
@@ -1803,6 +1813,27 @@ LnmLinksetResponse ToLnmResponse(
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>());
}
string? ExtractSeverity(IReadOnlyDictionary<string, object?> severityDict)
{
if (severityDict.TryGetValue("system", out var systemObj) && systemObj is string system && !string.IsNullOrWhiteSpace(system) &&
severityDict.TryGetValue("score", out var scoreObj))
{
return $"{system}:{scoreObj}";
}
if (severityDict.TryGetValue("score", out var scoreOnly) && scoreOnly is not null)
{
return scoreOnly.ToString();
}
if (severityDict.TryGetValue("value", out var value) && value is string valueString && !string.IsNullOrWhiteSpace(valueString))
{
return valueString;
}
return null;
}
IResult JsonResult<T>(T value, int? statusCode = null)
{
var payload = JsonSerializer.Serialize(value, Program.JsonOptions);

View File

@@ -241,6 +241,7 @@ components:
properties:
aliases: { type: array, items: { type: string } }
purl: { type: array, items: { type: string } }
cpe: { type: array, items: { type: string } }
versions: { type: array, items: { type: string } }
ranges: { type: array, items: { type: object } }
severities: { type: array, items: { type: object } }

View File

@@ -20,10 +20,14 @@ public sealed record AdvisoryLinkset(
public sealed record AdvisoryLinksetNormalized(
IReadOnlyList<string>? Purls,
IReadOnlyList<string>? Cpes,
IReadOnlyList<string>? Versions,
IReadOnlyList<Dictionary<string, object?>>? Ranges,
IReadOnlyList<Dictionary<string, object?>>? Severities)
{
public List<string>? CpesToList()
=> Cpes is null ? null : Cpes.ToList();
public List<BsonDocument>? RangesToBson()
=> Ranges is null ? null : Ranges.Select(BsonDocumentHelper.FromDictionary).ToList();

View File

@@ -12,7 +12,7 @@ internal static class AdvisoryLinksetNormalization
public static AdvisoryLinksetNormalized? FromRawLinkset(RawLinkset linkset)
{
ArgumentNullException.ThrowIfNull(linkset);
return Build(linkset.PackageUrls);
return Build(linkset.PackageUrls, linkset.Cpes);
}
public static AdvisoryLinksetNormalized? FromPurls(IEnumerable<string>? purls)
@@ -22,7 +22,7 @@ internal static class AdvisoryLinksetNormalization
return null;
}
return Build(purls);
return Build(purls, Enumerable.Empty<string>());
}
public static (AdvisoryLinksetNormalized? normalized, double? confidence, IReadOnlyList<AdvisoryLinksetConflict> conflicts) FromRawLinksetWithConfidence(
@@ -31,7 +31,7 @@ internal static class AdvisoryLinksetNormalization
{
ArgumentNullException.ThrowIfNull(linkset);
var normalized = Build(linkset.PackageUrls);
var normalized = Build(linkset.PackageUrls, linkset.Cpes);
var inputs = new[]
{
@@ -51,18 +51,19 @@ internal static class AdvisoryLinksetNormalization
return (normalized, coerced, conflicts);
}
private static AdvisoryLinksetNormalized? Build(IEnumerable<string> purlValues)
private static AdvisoryLinksetNormalized? Build(IEnumerable<string> purlValues, IEnumerable<string>? cpeValues)
{
var normalizedPurls = NormalizePurls(purlValues);
var normalizedCpes = NormalizeCpes(cpeValues);
var versions = ExtractVersions(normalizedPurls);
var ranges = BuildVersionRanges(normalizedPurls);
if (normalizedPurls.Count == 0 && versions.Count == 0 && ranges.Count == 0)
if (normalizedPurls.Count == 0 && normalizedCpes.Count == 0 && versions.Count == 0 && ranges.Count == 0)
{
return null;
}
return new AdvisoryLinksetNormalized(normalizedPurls, versions, ranges, null);
return new AdvisoryLinksetNormalized(normalizedPurls, normalizedCpes, versions, ranges, null);
}
private static List<string> NormalizePurls(IEnumerable<string> purls)
@@ -147,6 +148,31 @@ internal static class AdvisoryLinksetNormalization
return ranges;
}
private static List<string> NormalizeCpes(IEnumerable<string>? cpes)
{
if (cpes is null)
{
return new List<string>(capacity: 0);
}
var distinct = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var cpe in cpes)
{
var normalized = Validation.TrimToNull(cpe);
if (normalized is null)
{
continue;
}
if (LinksetNormalization.TryNormalizeCpe(normalized, out var canonical) && !string.IsNullOrEmpty(canonical))
{
distinct.Add(canonical);
}
}
return distinct.ToList();
}
private static bool LooksLikeRange(string value)
{
return value.IndexOfAny(new[] { '^', '~', '*', ' ', ',', '|', '>' , '<' }) >= 0 ||

View File

@@ -61,6 +61,11 @@ public sealed class AdvisoryLinksetNormalizedDocument
public List<string>? Purls { get; set; }
= new();
[BsonElement("cpes")]
[BsonIgnoreIfNull]
public List<string>? Cpes { get; set; }
= new();
[BsonElement("versions")]
[BsonIgnoreIfNull]
public List<string>? Versions { get; set; }

View File

@@ -125,6 +125,7 @@ internal sealed class ConcelierMongoLinksetStore : IMongoAdvisoryLinksetStore
Normalized = linkset.Normalized is null ? null : new AdvisoryLinksetNormalizedDocument
{
Purls = linkset.Normalized.Purls is null ? null : new List<string>(linkset.Normalized.Purls),
Cpes = linkset.Normalized.Cpes is null ? null : new List<string>(linkset.Normalized.Cpes),
Versions = linkset.Normalized.Versions is null ? null : new List<string>(linkset.Normalized.Versions),
Ranges = linkset.Normalized.RangesToBson(),
Severities = linkset.Normalized.SeveritiesToBson(),
@@ -141,6 +142,7 @@ internal sealed class ConcelierMongoLinksetStore : IMongoAdvisoryLinksetStore
doc.Observations.ToImmutableArray(),
doc.Normalized is null ? null : new CoreLinksets.AdvisoryLinksetNormalized(
doc.Normalized.Purls,
doc.Normalized.Cpes,
doc.Normalized.Versions,
doc.Normalized.Ranges?.Select(ToDictionary).ToList(),
doc.Normalized.Severities?.Select(ToDictionary).ToList()),

View File

@@ -214,6 +214,7 @@ internal sealed class EnsureLinkNotMergeCollectionsMigration : IMongoMigration
{ "properties", new BsonDocument
{
{ "purls", new BsonDocument { { "bsonType", new BsonArray { "array", "null" } }, { "items", new BsonDocument("bsonType", "string") } } },
{ "cpes", new BsonDocument { { "bsonType", new BsonArray { "array", "null" } }, { "items", new BsonDocument("bsonType", "string") } } },
{ "versions", new BsonDocument { { "bsonType", new BsonArray { "array", "null" } }, { "items", new BsonDocument("bsonType", "string") } } },
{ "ranges", new BsonDocument { { "bsonType", new BsonArray { "array", "null" } }, { "items", new BsonDocument("bsonType", "object") } } },
{ "severities", new BsonDocument { { "bsonType", new BsonArray { "array", "null" } }, { "items", new BsonDocument("bsonType", "object") } } }

View File

@@ -14,17 +14,17 @@ public sealed class AdvisoryLinksetQueryServiceTests
{
new("tenant", "ghsa", "adv-003",
ImmutableArray.Create("obs-003"),
new AdvisoryLinksetNormalized(new[]{"pkg:npm/a"}, new[]{"1.0.0"}, null, null),
new AdvisoryLinksetNormalized(new[]{"pkg:npm/a"}, null, new[]{"1.0.0"}, null, null),
null, null, null,
DateTimeOffset.Parse("2025-11-10T12:00:00Z"), null),
new("tenant", "ghsa", "adv-002",
ImmutableArray.Create("obs-002"),
new AdvisoryLinksetNormalized(new[]{"pkg:npm/b"}, new[]{"2.0.0"}, null, null),
new AdvisoryLinksetNormalized(new[]{"pkg:npm/b"}, null, new[]{"2.0.0"}, null, null),
null, null, null,
DateTimeOffset.Parse("2025-11-09T12:00:00Z"), null),
new("tenant", "ghsa", "adv-001",
ImmutableArray.Create("obs-001"),
new AdvisoryLinksetNormalized(new[]{"pkg:npm/c"}, new[]{"3.0.0"}, null, null),
new AdvisoryLinksetNormalized(new[]{"pkg:npm/c"}, null, new[]{"3.0.0"}, null, null),
null, null, null,
DateTimeOffset.Parse("2025-11-08T12:00:00Z"), null),
};

View File

@@ -18,6 +18,7 @@ public class PolicyAuthSignalFactoryTests
ObservationIds: ImmutableArray.Create("obs-1"),
Normalized: new AdvisoryLinksetNormalized(
Purls: new[] { "purl:pkg:maven/org.example/app@1.2.3" },
Cpes: null,
Versions: Array.Empty<string>(),
Ranges: null,
Severities: null),

View File

@@ -18,6 +18,7 @@ public class AdvisorySummaryMapperTests
ObservationIds: ImmutableArray.Create("obs1", "obs2"),
Normalized: new AdvisoryLinksetNormalized(
Purls: new[] { "pkg:maven/log4j/log4j@2.17.1" },
Cpes: null,
Versions: null,
Ranges: null,
Severities: null),

View File

@@ -36,18 +36,28 @@
<PackageReference Include="SharpCompress" Version="0.41.0" />
</ItemGroup>
<ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests')) and '$(UseConcelierTestInfra)' != 'false'">
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests')) and '$(UseConcelierTestInfra)' != 'false'">
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />
<Compile Include="$(ConcelierSharedTestsPath)AssemblyInfo.cs" Link="Shared\AssemblyInfo.cs" Condition="'$(ConcelierSharedTestsPath)' != ''" />
<Compile Include="$(ConcelierSharedTestsPath)MongoFixtureCollection.cs" Link="Shared\MongoFixtureCollection.cs" Condition="'$(ConcelierSharedTestsPath)' != ''" />
<ProjectReference Include="$(ConcelierTestingPath)StellaOps.Concelier.Testing.csproj" Condition="'$(ConcelierTestingPath)' != ''" />
<Using Include="StellaOps.Concelier.Testing" />
<Using Include="Xunit" />
</ItemGroup>
</Project>
<Compile Include="$(ConcelierSharedTestsPath)AssemblyInfo.cs" Link="Shared\AssemblyInfo.cs" Condition="'$(ConcelierSharedTestsPath)' != ''" />
<Compile Include="$(ConcelierSharedTestsPath)MongoFixtureCollection.cs" Link="Shared\MongoFixtureCollection.cs" Condition="'$(ConcelierSharedTestsPath)' != ''" />
<ProjectReference Include="$(ConcelierTestingPath)StellaOps.Concelier.Testing.csproj" Condition="'$(ConcelierTestingPath)' != ''" />
<Using Include="StellaOps.Concelier.Testing" />
<Using Include="Xunit" />
</ItemGroup>
<!-- DEVOPS-OPENSSL-11-001: ship OpenSSL 1.1 shim with test outputs for Mongo2Go on Linux -->
<ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests'))">
<None Include="$(MSBuildThisFileDirectory)..\tests\native\openssl-1.1\linux-x64\*.so.1.1"
Link="native/linux-x64/%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
<!-- DEVOPS-OPENSSL-11-002: auto-enable shim at test start for Mongo2Go suites -->
<Compile Include="$(MSBuildThisFileDirectory)..\tests\shared\OpenSslLegacyShim.cs" Link="Shared/OpenSslLegacyShim.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\tests\shared\OpenSslAutoInit.cs" Link="Shared/OpenSslAutoInit.cs" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Excititor.WebService.Contracts;
public sealed record GraphOverlaysResponse(
[property: JsonPropertyName("items")] IReadOnlyList<GraphOverlayItem> Items,
[property: JsonPropertyName("cached")] bool Cached,
[property: JsonPropertyName("cacheAgeMs")] long? CacheAgeMs);
public sealed record GraphOverlayItem(
[property: JsonPropertyName("purl")] string Purl,
[property: JsonPropertyName("summary")] GraphOverlaySummary Summary,
[property: JsonPropertyName("latestModifiedAt")] DateTimeOffset? LatestModifiedAt,
[property: JsonPropertyName("justifications")] IReadOnlyList<string> Justifications,
[property: JsonPropertyName("provenance")] GraphOverlayProvenance Provenance);
public sealed record GraphOverlaySummary(
[property: JsonPropertyName("open")] int Open,
[property: JsonPropertyName("not_affected")] int NotAffected,
[property: JsonPropertyName("under_investigation")] int UnderInvestigation,
[property: JsonPropertyName("no_statement")] int NoStatement);
public sealed record GraphOverlayProvenance(
[property: JsonPropertyName("sources")] IReadOnlyList<string> Sources,
[property: JsonPropertyName("lastEvidenceHash")] string? LastEvidenceHash);

View File

@@ -1,58 +0,0 @@
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.WebService.Options;
namespace StellaOps.Excititor.WebService.Controllers;
[ApiController]
[Route("v1/graph")]
public class GraphController : ControllerBase
{
private readonly GraphOptions _options;
public GraphController(IOptions<GraphOptions> options)
{
_options = options.Value;
}
[HttpPost("linkouts")]
public IActionResult Linkouts([FromBody] LinkoutRequest request)
{
if (request == null || request.Purls == null || request.Purls.Count == 0)
{
return BadRequest("purls are required");
}
if (request.Purls.Count > _options.MaxPurls)
{
return BadRequest($"purls limit exceeded (max {_options.MaxPurls})");
}
return StatusCode(503, "Graph linkouts pending storage integration.");
}
[HttpGet("overlays")]
public IActionResult Overlays([FromQuery(Name = "purl")] List<string> purls, [FromQuery] bool includeJustifications = false)
{
if (purls == null || purls.Count == 0)
{
return BadRequest("purl query parameter is required");
}
if (purls.Count > _options.MaxPurls)
{
return BadRequest($"purls limit exceeded (max {_options.MaxPurls})");
}
return StatusCode(503, "Graph overlays pending storage integration.");
}
}
public sealed record LinkoutRequest
{
public string Tenant { get; init; } = string.Empty;
public List<string> Purls { get; init; } = new();
public bool IncludeJustifications { get; init; }
public bool IncludeProvenance { get; init; } = true;
}

View File

@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.WebService.Contracts;
namespace StellaOps.Excititor.WebService.Graph;
internal static class GraphOverlayFactory
{
public static IReadOnlyList<GraphOverlayItem> Build(
IReadOnlyList<string> orderedPurls,
IReadOnlyList<VexObservation> observations,
bool includeJustifications)
{
if (orderedPurls is null)
{
throw new ArgumentNullException(nameof(orderedPurls));
}
if (observations is null)
{
throw new ArgumentNullException(nameof(observations));
}
var observationsByPurl = observations
.SelectMany(obs => obs.Linkset.Purls.Select(purl => (purl, obs)))
.GroupBy(tuple => tuple.purl, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Select(t => t.obs).ToImmutableArray(), StringComparer.OrdinalIgnoreCase);
var items = new List<GraphOverlayItem>(orderedPurls.Count);
foreach (var input in orderedPurls)
{
if (!observationsByPurl.TryGetValue(input, out var obsForPurl) || obsForPurl.Length == 0)
{
items.Add(new GraphOverlayItem(
Purl: input,
Summary: new GraphOverlaySummary(0, 0, 0, 0),
LatestModifiedAt: null,
Justifications: Array.Empty<string>(),
Provenance: new GraphOverlayProvenance(Array.Empty<string>(), null)));
continue;
}
var open = 0;
var notAffected = 0;
var underInvestigation = 0;
var noStatement = 0;
var justifications = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var sources = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
string? lastEvidenceHash = null;
DateTimeOffset? latestModifiedAt = null;
foreach (var obs in obsForPurl)
{
sources.Add(obs.ProviderId);
if (latestModifiedAt is null || obs.CreatedAt > latestModifiedAt.Value)
{
latestModifiedAt = obs.CreatedAt;
lastEvidenceHash = obs.Upstream.ContentHash;
}
var matchingStatements = obs.Statements
.Where(stmt => PurlMatches(stmt, input, obs.Linkset.Purls))
.ToArray();
if (matchingStatements.Length == 0)
{
noStatement++;
continue;
}
foreach (var stmt in matchingStatements)
{
switch (stmt.Status)
{
case VexClaimStatus.NotAffected:
notAffected++;
break;
case VexClaimStatus.UnderInvestigation:
underInvestigation++;
break;
default:
open++;
break;
}
if (includeJustifications && stmt.Justification is not null)
{
justifications.Add(stmt.Justification!.ToString()!);
}
}
}
items.Add(new GraphOverlayItem(
Purl: input,
Summary: new GraphOverlaySummary(open, notAffected, underInvestigation, noStatement),
LatestModifiedAt: latestModifiedAt,
Justifications: includeJustifications
? justifications.ToArray()
: Array.Empty<string>(),
Provenance: new GraphOverlayProvenance(sources.ToArray(), lastEvidenceHash)));
}
return items;
}
private static bool PurlMatches(VexObservationStatement stmt, string inputPurl, ImmutableArray<string> linksetPurls)
{
if (!string.IsNullOrWhiteSpace(stmt.Purl) && stmt.Purl.Equals(inputPurl, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (linksetPurls.IsDefaultOrEmpty)
{
return false;
}
return linksetPurls.Any(p => p.Equals(inputPurl, StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -199,6 +199,33 @@ public partial class Program
return Math.Clamp(parsed, min, max);
}
private static IReadOnlyList<string> NormalizePurls(string[]? purls)
{
if (purls is null || purls.Length == 0)
{
return Array.Empty<string>();
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var ordered = new List<string>(purls.Length);
foreach (var purl in purls)
{
var trimmed = purl?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
continue;
}
var normalized = trimmed.ToLowerInvariant();
if (seen.Add(normalized))
{
ordered.Add(normalized);
}
}
return ordered;
}
private static VexObservationStatementResponse ToResponse(VexObservationStatementProjection projection)
{
var scope = projection.Scope;
@@ -234,4 +261,8 @@ public partial class Program
signature.Issuer,
signature.VerifiedAt));
}
private sealed record CachedGraphOverlay(
IReadOnlyList<GraphOverlayItem> Items,
DateTimeOffset CachedAt);
}

View File

@@ -37,6 +37,7 @@ using MongoDB.Driver;
using MongoDB.Bson;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Graph;
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
@@ -523,10 +524,70 @@ var options = new VexObservationQueryOptions(
NextCursor: advisories.Count >= 200 ? $"{advisories[^1].AdvisoryId}:{advisories[^1].Source}" : null));
}
var response = new GraphLinkoutsResponse(items, notFound);
var response = new GraphLinkoutsResponse(items, notFound);
return Results.Ok(response);
}).WithName("PostGraphLinkouts");
// Cartographer overlays
app.MapGet("/v1/graph/overlays", async (
HttpContext context,
[FromQuery(Name = "purl")] string[]? purls,
[FromQuery] bool includeJustifications,
IOptions<VexMongoStorageOptions> storageOptions,
IOptions<GraphOptions> graphOptions,
IVexObservationQueryService queryService,
IMemoryCache cache,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError))
{
return tenantError;
}
var orderedPurls = NormalizePurls(purls);
if (orderedPurls.Count == 0)
{
return Results.BadRequest("purl query parameter is required");
}
if (orderedPurls.Count > graphOptions.Value.MaxPurls)
{
return Results.BadRequest($"purls limit exceeded (max {graphOptions.Value.MaxPurls})");
}
var cacheKey = $"graph-overlays:{tenant}:{includeJustifications}:{string.Join('|', orderedPurls)}";
var now = timeProvider.GetUtcNow();
if (cache.TryGetValue<CachedGraphOverlay>(cacheKey, out var cached) && cached is not null)
{
var ageMs = (long)Math.Max(0, (now - cached.CachedAt).TotalMilliseconds);
return Results.Ok(new GraphOverlaysResponse(cached.Items, true, ageMs));
}
var options = new VexObservationQueryOptions(
tenant: tenant,
purls: orderedPurls,
limit: graphOptions.Value.MaxAdvisoriesPerPurl * orderedPurls.Count);
VexObservationQueryResult result;
try
{
result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
}
catch (FormatException ex)
{
return Results.BadRequest(ex.Message);
}
var overlays = GraphOverlayFactory.Build(orderedPurls, result.Observations, includeJustifications);
var response = new GraphOverlaysResponse(overlays, false, null);
cache.Set(cacheKey, new CachedGraphOverlay(overlays, now), TimeSpan.FromSeconds(graphOptions.Value.OverlayTtlSeconds));
return Results.Ok(response);
}).WithName("GetGraphOverlays");
app.MapPost("/ingest/vex", async (
HttpContext context,
VexIngestRequest request,

View File

@@ -0,0 +1,108 @@
using System;
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.WebService.Graph;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class GraphOverlayFactoryTests
{
[Fact]
public void Build_ComputesSummariesAndProvenancePerPurl()
{
var now = DateTimeOffset.UtcNow;
var observations = new[]
{
CreateObservation(
providerId: "redhat",
createdAt: now.AddMinutes(-5),
purls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
statements: new[]
{
new VexObservationStatement(
vulnerabilityId: "CVE-2025-1000",
productKey: "pkg:rpm/redhat/openssl@1.1.1",
status: VexClaimStatus.NotAffected,
lastObserved: now,
justification: VexJustification.ComponentNotPresent,
purl: "pkg:rpm/redhat/openssl@1.1.1")
},
contentHash: "hash-old"),
CreateObservation(
providerId: "ubuntu",
createdAt: now,
purls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
statements: new[]
{
new VexObservationStatement(
vulnerabilityId: "CVE-2025-1001",
productKey: "pkg:rpm/redhat/openssl@1.1.1",
status: VexClaimStatus.UnderInvestigation,
lastObserved: now,
justification: null,
purl: "pkg:rpm/redhat/openssl@1.1.1")
},
contentHash: "hash-new"),
CreateObservation(
providerId: "oracle",
createdAt: now.AddMinutes(-1),
purls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
statements: Array.Empty<VexObservationStatement>(),
contentHash: "hash-oracle")
};
var overlays = GraphOverlayFactory.Build(
orderedPurls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
observations: observations,
includeJustifications: true);
var overlay = Assert.Single(overlays);
Assert.Equal("pkg:rpm/redhat/openssl@1.1.1", overlay.Purl);
Assert.Equal(0, overlay.Summary.Open);
Assert.Equal(1, overlay.Summary.NotAffected);
Assert.Equal(1, overlay.Summary.UnderInvestigation);
Assert.Equal(1, overlay.Summary.NoStatement);
Assert.Equal(now, overlay.LatestModifiedAt);
Assert.Equal(new[] { "ComponentNotPresent" }, overlay.Justifications);
Assert.Equal("hash-new", overlay.Provenance.LastEvidenceHash);
Assert.Equal(new[] { "oracle", "redhat", "ubuntu" }, overlay.Provenance.Sources);
}
private static VexObservation CreateObservation(
string providerId,
DateTimeOffset createdAt,
string[] purls,
VexObservationStatement[] statements,
string contentHash)
{
return new VexObservation(
observationId: $"obs-{providerId}-{createdAt.ToUnixTimeMilliseconds()}",
tenant: "tenant-a",
providerId: providerId,
streamId: "csaf",
upstream: new VexObservationUpstream(
upstreamId: Guid.NewGuid().ToString("N"),
documentVersion: "1",
fetchedAt: createdAt,
receivedAt: createdAt,
contentHash: contentHash,
signature: new VexObservationSignature(present: true, format: "sig", keyId: null, signature: null)),
statements: statements.ToImmutableArray(),
content: new VexObservationContent(
format: "csaf",
specVersion: "1",
raw: JsonValue.Create("raw")!,
metadata: ImmutableDictionary<string, string>.Empty),
linkset: new VexObservationLinkset(
aliases: Array.Empty<string>(),
purls: purls,
cpes: Array.Empty<string>(),
references: Array.Empty<VexObservationReference>()),
createdAt: createdAt,
supersedes: ImmutableArray<string>.Empty,
attributes: ImmutableDictionary<string, string>.Empty);
}
}

View File

@@ -34,6 +34,7 @@
<Compile Include="TestAuthentication.cs" />
<Compile Include="TestServiceOverrides.cs" />
<Compile Include="TestWebApplicationFactory.cs" />
<Compile Include="GraphOverlayFactoryTests.cs" />
<Compile Include="AttestationVerifyEndpointTests.cs" />
</ItemGroup>
</Project>

View File

@@ -13,7 +13,10 @@ cat > "$STAGE/layers/observations.ndjson" <<'DATA'
{"id":"obs-002","purl":"pkg:npm/lodash@4.17.21","advisory":"CVE-2024-9999","severity":"high","source":"vendor-b","timestamp":"2025-10-15T00:00:00Z"}
DATA
cat > "$STAGE/layers/time-anchor.json" <<'DATA'
if [[ -n "${TIME_ANCHOR_FILE:-}" && -f "${TIME_ANCHOR_FILE}" ]]; then
cp "${TIME_ANCHOR_FILE}" "$STAGE/layers/time-anchor.json"
else
cat > "$STAGE/layers/time-anchor.json" <<'DATA'
{
"authority": "stellaops-airgap-test",
"generatedAt": "2025-11-01T00:00:00Z",
@@ -29,6 +32,7 @@ cat > "$STAGE/layers/time-anchor.json" <<'DATA'
]
}
DATA
fi
cat > "$STAGE/indexes/observations.index" <<'DATA'
obs-001 layers/observations.ndjson:1

View File

@@ -10,26 +10,16 @@ using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class OpenApiEndpointTests : IClassFixture<WebApplicationFactory<WebServiceAssemblyMarker>>
public sealed class OpenApiEndpointTests : IClassFixture<NotifierApplicationFactory>
{
private readonly HttpClient _client;
private readonly InMemoryPackApprovalRepository _packRepo = new();
private readonly InMemoryLockRepository _lockRepo = new();
private readonly InMemoryAuditRepository _auditRepo = new();
public OpenApiEndpointTests(WebApplicationFactory<WebServiceAssemblyMarker> factory)
public OpenApiEndpointTests(NotifierApplicationFactory factory)
{
_client = factory
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddSingleton<INotifyPackApprovalRepository>(_packRepo);
services.AddSingleton<INotifyLockRepository>(_lockRepo);
services.AddSingleton<INotifyAuditRepository>(_auditRepo);
});
})
.CreateClient();
_client = factory.CreateClient();
}
[Fact]
@@ -89,4 +79,20 @@ public sealed class OpenApiEndpointTests : IClassFixture<WebApplicationFactory<W
resumeValues.Contains("rt-ok"));
Assert.True(_packRepo.Exists("tenant-a", Guid.Parse("00000000-0000-0000-0000-000000000002"), "offline-kit"));
}
[Fact]
public async Task PackApprovals_acknowledgement_requires_tenant_and_token()
{
var ackContent = new StringContent("""{"ackToken":"token-123"}""", Encoding.UTF8, "application/json");
var ackResponse = await _client.PostAsync("/api/v1/notify/pack-approvals/offline-kit/ack", ackContent, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, ackResponse.StatusCode);
var ackReq = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/pack-approvals/offline-kit/ack")
{
Content = ackContent
};
ackReq.Headers.Add("X-StellaOps-Tenant", "tenant-a");
var goodResponse = await _client.SendAsync(ackReq, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, goodResponse.StatusCode);
}
}

View File

@@ -20,9 +20,10 @@
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Content Include="TestContent/**" CopyToOutputDirectory="PreserveNewest" />
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />

View File

@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notifier.WebService;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Tests.Support;
internal sealed class NotifierApplicationFactory : WebApplicationFactory<WebServiceAssemblyMarker>
{
private readonly InMemoryPackApprovalRepository _packRepo;
private readonly InMemoryLockRepository _lockRepo;
private readonly InMemoryAuditRepository _auditRepo;
public NotifierApplicationFactory(
InMemoryPackApprovalRepository packRepo,
InMemoryLockRepository lockRepo,
InMemoryAuditRepository auditRepo)
{
_packRepo = packRepo;
_lockRepo = lockRepo;
_auditRepo = auditRepo;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseContentRoot(Path.Combine(Directory.GetCurrentDirectory(), "TestContent"));
builder.ConfigureServices(services =>
{
services.AddSingleton<INotifyPackApprovalRepository>(_packRepo);
services.AddSingleton<INotifyLockRepository>(_lockRepo);
services.AddSingleton<INotifyAuditRepository>(_auditRepo);
});
}
}

View File

@@ -0,0 +1,613 @@
# OpenAPI 3.1 specification for StellaOps Notifier WebService (draft)
openapi: 3.1.0
info:
title: StellaOps Notifier API
version: 0.6.0-draft
description: |
Contract for Notifications Studio (Notifier) covering rules, templates, incidents,
and quiet hours. Uses the platform error envelope and tenant header `X-StellaOps-Tenant`.
servers:
- url: https://api.stellaops.example.com
description: Production
- url: https://api.dev.stellaops.example.com
description: Development
security:
- oauth2: [notify.viewer]
- oauth2: [notify.operator]
- oauth2: [notify.admin]
paths:
/api/v1/notify/rules:
get:
summary: List notification rules
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
responses:
'200':
description: Paginated rule list
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/NotifyRule' }
nextPageToken:
type: string
examples:
default:
value:
items:
- ruleId: rule-critical
tenantId: tenant-dev
name: Critical scanner verdicts
enabled: true
match:
eventKinds: [scanner.report.ready]
minSeverity: critical
actions:
- actionId: act-slack-critical
channel: chn-slack-soc
template: tmpl-critical
digest: instant
nextPageToken: null
default:
$ref: '#/components/responses/Error'
post:
summary: Create a notification rule
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
examples:
create-rule:
value:
ruleId: rule-attest-fail
tenantId: tenant-dev
name: Attestation failures → SOC
enabled: true
match:
eventKinds: [attestor.verification.failed]
actions:
- actionId: act-soc
channel: chn-webhook-soc
template: tmpl-attest-verify-fail
responses:
'201':
description: Rule created
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/rules/{ruleId}:
get:
summary: Fetch a rule
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/RuleId'
responses:
'200':
description: Rule
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
patch:
summary: Update a rule (partial)
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/RuleId'
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON Merge Patch
responses:
'200':
description: Updated rule
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/templates:
get:
summary: List templates
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- name: key
in: query
description: Filter by template key
schema: { type: string }
responses:
'200':
description: Templates
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
post:
summary: Create a template
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
responses:
'201':
description: Template created
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/templates/{templateId}:
get:
summary: Fetch a template
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/TemplateId'
responses:
'200':
description: Template
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
patch:
summary: Update a template (partial)
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/TemplateId'
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON Merge Patch
responses:
'200':
description: Updated template
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/incidents:
get:
summary: List incidents (paged)
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
responses:
'200':
description: Incident page
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/Incident' }
nextPageToken: { type: string }
default:
$ref: '#/components/responses/Error'
post:
summary: Raise an incident (ops/toggle/override)
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Incident' }
examples:
start-incident:
value:
incidentId: inc-telemetry-outage
kind: outage
severity: major
startedAt: 2025-11-17T04:02:00Z
shortDescription: "Telemetry pipeline degraded; burn-rate breach"
metadata:
source: slo-evaluator
responses:
'202':
description: Incident accepted
default:
$ref: '#/components/responses/Error'
/api/v1/notify/incidents/{incidentId}/ack:
post:
summary: Acknowledge an incident notification
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/IncidentId'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ackToken:
type: string
description: DSSE-signed acknowledgement token
responses:
'204':
description: Acknowledged
default:
$ref: '#/components/responses/Error'
/api/v1/notify/quiet-hours:
get:
summary: Get quiet-hours schedule
tags: [QuietHours]
parameters:
- $ref: '#/components/parameters/Tenant'
responses:
'200':
description: Quiet hours schedule
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
examples:
current:
value:
quietHoursId: qh-default
windows:
- timezone: UTC
days: [Mon, Tue, Wed, Thu, Fri]
start: "22:00"
end: "06:00"
exemptions:
- eventKinds: [attestor.verification.failed]
reason: "Always alert for attestation failures"
default:
$ref: '#/components/responses/Error'
post:
summary: Set quiet-hours schedule
tags: [QuietHours]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
responses:
'200':
description: Updated quiet hours
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/pack-approvals:
post:
summary: Ingest pack approval decision
tags: [PackApprovals]
operationId: ingestPackApproval
security:
- oauth2: [notify.operator]
- hmac: []
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/PackApprovalEvent' }
examples:
approval-granted:
value:
eventId: "20e4e5fe-3d4a-4f57-9f9b-b1a1c1111111"
issuedAt: "2025-11-17T16:00:00Z"
kind: "pack.approval.granted"
packId: "offline-kit-2025-11"
policy:
id: "policy-123"
version: "v5"
decision: "approved"
actor: "task-runner"
resumeToken: "rt-abc123"
summary: "All required attestations verified."
labels:
environment: "prod"
approver: "ops"
responses:
'202':
description: Accepted; durable write queued for processing.
headers:
X-Resume-After:
description: Resume token echo or replacement
schema: { type: string }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/pack-approvals/{packId}/ack:
post:
summary: Acknowledge a pack approval notification
tags: [PackApprovals]
operationId: ackPackApproval
parameters:
- $ref: '#/components/parameters/Tenant'
- name: packId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ackToken: { type: string }
required: [ackToken]
responses:
'204':
description: Acknowledged
default:
$ref: '#/components/responses/Error'
components:
securitySchemes:
oauth2:
type: oauth2
flows:
clientCredentials:
tokenUrl: https://auth.stellaops.example.com/oauth/token
scopes:
notify.viewer: Read-only Notifier access
notify.operator: Manage rules/templates/incidents within tenant
notify.admin: Tenant-scoped administration
hmac:
type: http
scheme: bearer
description: Pre-shared HMAC token (air-gap friendly) referenced by secretRef.
parameters:
Tenant:
name: X-StellaOps-Tenant
in: header
required: true
description: Tenant slug
schema: { type: string }
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
description: Stable UUID to dedupe retries.
schema: { type: string, format: uuid }
PageSize:
name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
PageToken:
name: pageToken
in: query
schema: { type: string }
RuleId:
name: ruleId
in: path
required: true
schema: { type: string }
TemplateId:
name: templateId
in: path
required: true
schema: { type: string }
IncidentId:
name: incidentId
in: path
required: true
schema: { type: string }
responses:
Error:
description: Standard error envelope
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
examples:
validation:
value:
error:
code: validation_failed
message: "quietHours.windows[0].start must be HH:mm"
traceId: "f62f3c2b9c8e4c53"
schemas:
ErrorEnvelope:
type: object
required: [error]
properties:
error:
type: object
required: [code, message, traceId]
properties:
code: { type: string }
message: { type: string }
traceId: { type: string }
NotifyRule:
type: object
required: [ruleId, tenantId, name, match, actions]
properties:
ruleId: { type: string }
tenantId: { type: string }
name: { type: string }
description: { type: string }
enabled: { type: boolean, default: true }
match: { $ref: '#/components/schemas/RuleMatch' }
actions:
type: array
items: { $ref: '#/components/schemas/RuleAction' }
labels:
type: object
additionalProperties: { type: string }
metadata:
type: object
additionalProperties: { type: string }
RuleMatch:
type: object
properties:
eventKinds:
type: array
items: { type: string }
minSeverity: { type: string, enum: [info, low, medium, high, critical] }
verdicts:
type: array
items: { type: string }
labels:
type: array
items: { type: string }
kevOnly: { type: boolean }
RuleAction:
type: object
required: [actionId, channel]
properties:
actionId: { type: string }
channel: { type: string }
template: { type: string }
digest: { type: string, description: "Digest window key e.g. instant|5m|15m|1h|1d" }
throttle: { type: string, description: "ISO-8601 duration, e.g. PT5M" }
locale: { type: string }
enabled: { type: boolean, default: true }
metadata:
type: object
additionalProperties: { type: string }
NotifyTemplate:
type: object
required: [templateId, tenantId, key, channelType, locale, body, renderMode, format]
properties:
templateId: { type: string }
tenantId: { type: string }
key: { type: string }
channelType: { type: string, enum: [slack, teams, email, webhook, custom] }
locale: { type: string, description: "BCP-47, lower-case" }
renderMode: { type: string, enum: [Markdown, Html, AdaptiveCard, PlainText, Json] }
format: { type: string, enum: [slack, teams, email, webhook, json] }
description: { type: string }
body: { type: string }
metadata:
type: object
additionalProperties: { type: string }
Incident:
type: object
required: [incidentId, kind, severity, startedAt]
properties:
incidentId: { type: string }
kind: { type: string, description: "outage|degradation|security|ops-drill" }
severity: { type: string, enum: [minor, major, critical] }
startedAt: { type: string, format: date-time }
endedAt: { type: string, format: date-time }
shortDescription: { type: string }
description: { type: string }
metadata:
type: object
additionalProperties: { type: string }
PackApprovalEvent:
type: object
required:
- eventId
- issuedAt
- kind
- packId
- decision
- actor
properties:
eventId: { type: string, format: uuid }
issuedAt: { type: string, format: date-time }
kind:
type: string
enum: [pack.approval.granted, pack.approval.denied, pack.policy.override]
packId: { type: string }
policy:
type: object
properties:
id: { type: string }
version: { type: string }
decision:
type: string
enum: [approved, denied, overridden]
actor: { type: string }
resumeToken:
type: string
description: Opaque token for at-least-once resume.
summary: { type: string }
labels:
type: object
additionalProperties: { type: string }
QuietHours:
type: object
required: [quietHoursId, windows]
properties:
quietHoursId: { type: string }
windows:
type: array
items: { $ref: '#/components/schemas/QuietHoursWindow' }
exemptions:
type: array
description: Event kinds that bypass quiet hours
items:
type: object
properties:
eventKinds:
type: array
items: { type: string }
reason: { type: string }
QuietHoursWindow:
type: object
required: [timezone, days, start, end]
properties:
timezone: { type: string, description: "IANA TZ, e.g., UTC" }
days:
type: array
items:
type: string
enum: [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
start: { type: string, description: "HH:mm" }
end: { type: string, description: "HH:mm" }

View File

@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Notifier.WebService.Contracts;
public sealed class PackApprovalAckRequest
{
[Required]
public string AckToken { get; init; } = string.Empty;
}

View File

@@ -118,6 +118,50 @@ app.MapPost("/api/v1/notify/pack-approvals", async (
return Results.Accepted();
});
app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
HttpContext context,
string packId,
PackApprovalAckRequest request,
INotifyLockRepository locks,
INotifyAuditRepository audit,
TimeProvider timeProvider) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
if (string.IsNullOrWhiteSpace(request.AckToken))
{
return Results.BadRequest(Error("ack_token_missing", "AckToken is required.", context));
}
var lockKey = $"pack-approvals-ack|{tenantId}|{packId}|{request.AckToken}";
var reserved = await locks.TryAcquireAsync(tenantId, lockKey, "pack-approvals-ack", TimeSpan.FromMinutes(10), context.RequestAborted)
.ConfigureAwait(false);
if (!reserved)
{
return Results.StatusCode(StatusCodes.Status200OK);
}
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = "pack-approvals-ack",
Action = "pack.approval.acknowledged",
EntityId = packId,
EntityType = "pack-approval",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(JsonSerializer.Serialize(request))
};
await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
return Results.NoContent();
});
app.MapGet("/.well-known/openapi", (HttpContext context, OpenApiDocumentCache cache) =>
{
context.Response.Headers.CacheControl = "public, max-age=300";

View File

@@ -12,7 +12,9 @@ public sealed class OpenApiDocumentCache
var path = Path.Combine(environment.ContentRootPath, "openapi", "notify-openapi.yaml");
if (!File.Exists(path))
{
throw new FileNotFoundException("OpenAPI document not found.", path);
_document = string.Empty;
_hash = string.Empty;
return;
}
_document = File.ReadAllText(path, Encoding.UTF8);

View File

@@ -317,6 +317,75 @@ paths:
default:
$ref: '#/components/responses/Error'
/api/v1/notify/pack-approvals:
post:
summary: Ingest pack approval decision
tags: [PackApprovals]
operationId: ingestPackApproval
security:
- oauth2: [notify.operator]
- hmac: []
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/PackApprovalEvent' }
examples:
approval-granted:
value:
eventId: "20e4e5fe-3d4a-4f57-9f9b-b1a1c1111111"
issuedAt: "2025-11-17T16:00:00Z"
kind: "pack.approval.granted"
packId: "offline-kit-2025-11"
policy:
id: "policy-123"
version: "v5"
decision: "approved"
actor: "task-runner"
resumeToken: "rt-abc123"
summary: "All required attestations verified."
labels:
environment: "prod"
approver: "ops"
responses:
'202':
description: Accepted; durable write queued for processing.
headers:
X-Resume-After:
description: Resume token echo or replacement
schema: { type: string }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/pack-approvals/{packId}/ack:
post:
summary: Acknowledge a pack approval notification
tags: [PackApprovals]
operationId: ackPackApproval
parameters:
- $ref: '#/components/parameters/Tenant'
- name: packId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ackToken: { type: string }
required: [ackToken]
responses:
'204':
description: Acknowledged
default:
$ref: '#/components/responses/Error'
components:
securitySchemes:
oauth2:
@@ -328,6 +397,10 @@ components:
notify.viewer: Read-only Notifier access
notify.operator: Manage rules/templates/incidents within tenant
notify.admin: Tenant-scoped administration
hmac:
type: http
scheme: bearer
description: Pre-shared HMAC token (air-gap friendly) referenced by secretRef.
parameters:
Tenant:
name: X-StellaOps-Tenant
@@ -335,6 +408,12 @@ components:
required: true
description: Tenant slug
schema: { type: string }
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
description: Stable UUID to dedupe retries.
schema: { type: string, format: uuid }
PageSize:
name: pageSize
in: query
@@ -468,6 +547,39 @@ components:
type: object
additionalProperties: { type: string }
PackApprovalEvent:
type: object
required:
- eventId
- issuedAt
- kind
- packId
- decision
- actor
properties:
eventId: { type: string, format: uuid }
issuedAt: { type: string, format: date-time }
kind:
type: string
enum: [pack.approval.granted, pack.approval.denied, pack.policy.override]
packId: { type: string }
policy:
type: object
properties:
id: { type: string }
version: { type: string }
decision:
type: string
enum: [approved, denied, overridden]
actor: { type: string }
resumeToken:
type: string
description: Opaque token for at-least-once resume.
summary: { type: string }
labels:
type: object
additionalProperties: { type: string }
QuietHours:
type: object
required: [quietHoursId, windows]

View File

@@ -0,0 +1,71 @@
{
"templates": [
{
"templateId": "tmpl-pack-approval-slack-en",
"tenantId": "tenant-sample",
"key": "pack.approval.granted",
"channelType": "slack",
"locale": "en-US",
"renderMode": "Markdown",
"format": "slack",
"description": "Pack approval granted (Slack, English)",
"body": "*Pack approval granted*\nPack: {{packId}}\nPolicy: {{policy.id}} ({{policy.version}})\nDecision: {{decision}}\nResume: {{resumeToken}}\nSummary: {{summary}}\nLabels: {{#each labels}}{{@key}}={{this}} {{/each}}",
"metadata": {
"redaction": "safe",
"throttle": "PT5M"
}
},
{
"templateId": "tmpl-pack-approval-email-en",
"tenantId": "tenant-sample",
"key": "pack.approval.granted",
"channelType": "email",
"locale": "en-US",
"renderMode": "Html",
"format": "email",
"description": "Pack approval granted (Email, English)",
"body": "<h3>Pack approval granted</h3><p><strong>Pack:</strong> {{packId}}<br/><strong>Policy:</strong> {{policy.id}} ({{policy.version}})<br/><strong>Decision:</strong> {{decision}}<br/><strong>Resume:</strong> {{resumeToken}}<br/><strong>Summary:</strong> {{summary}}</p><p><strong>Labels:</strong> {{#each labels}}{{@key}}={{this}} {{/each}}</p>",
"metadata": {
"redaction": "safe",
"throttle": "PT5M",
"subject": "[Notify] Pack approval granted: {{packId}}"
}
}
],
"routingPredicates": [
{
"name": "pack-approval-default",
"match": {
"eventKinds": ["pack.approval.granted", "pack.approval.denied", "pack.policy.override"],
"labels": ["environment=prod"]
},
"actions": [
{
"channel": "slack:sec-approvals",
"template": "tmpl-pack-approval-slack-en",
"digest": "instant"
},
{
"channel": "email:ops-approvals",
"template": "tmpl-pack-approval-email-en",
"digest": "instant"
}
]
}
],
"redaction": {
"allow": [
"packId",
"policy.id",
"policy.version",
"decision",
"resumeToken",
"summary",
"labels.*"
],
"deny": [
"secrets",
"tokens"
]
}
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.AdvisoryAI;
internal sealed record AdvisoryAiKnob(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("default_value")] decimal DefaultValue,
[property: JsonPropertyName("min")] decimal Min,
[property: JsonPropertyName("max")] decimal Max,
[property: JsonPropertyName("step")] decimal Step,
[property: JsonPropertyName("description")] string Description);
internal sealed record AdvisoryAiKnobsProfile(
[property: JsonPropertyName("knobs")] IReadOnlyList<AdvisoryAiKnob> Knobs,
[property: JsonPropertyName("profile_hash")] string ProfileHash);

View File

@@ -0,0 +1,73 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Policy.Engine.AdvisoryAI;
/// <summary>
/// In-memory store for Advisory AI knobs (POLICY-ENGINE-31-001).
/// </summary>
internal sealed class AdvisoryAiKnobsService
{
private readonly TimeProvider _timeProvider;
private readonly object _lock = new();
private AdvisoryAiKnobsProfile _current;
public AdvisoryAiKnobsService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_current = BuildProfile(DefaultKnobs());
}
public AdvisoryAiKnobsProfile Get() => _current;
public AdvisoryAiKnobsProfile Set(IReadOnlyList<AdvisoryAiKnob> knobs)
{
var normalized = Normalize(knobs);
var profile = BuildProfile(normalized);
lock (_lock)
{
_current = profile;
}
return profile;
}
private AdvisoryAiKnobsProfile BuildProfile(IReadOnlyList<AdvisoryAiKnob> knobs)
{
var json = JsonSerializer.Serialize(knobs, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
return new AdvisoryAiKnobsProfile(knobs, hash);
}
private IReadOnlyList<AdvisoryAiKnob> Normalize(IReadOnlyList<AdvisoryAiKnob> knobs)
{
var normalized = knobs
.Where(k => !string.IsNullOrWhiteSpace(k.Name))
.Select(k => new AdvisoryAiKnob(
Name: k.Name.Trim().ToLowerInvariant(),
DefaultValue: k.DefaultValue,
Min: k.Min,
Max: k.Max,
Step: k.Step <= 0 ? 0.001m : k.Step,
Description: string.IsNullOrWhiteSpace(k.Description) ? string.Empty : k.Description.Trim()))
.OrderBy(k => k.Name, StringComparer.Ordinal)
.ToList();
return normalized;
}
private static IReadOnlyList<AdvisoryAiKnob> DefaultKnobs() =>
new[]
{
new AdvisoryAiKnob("ai_signal_weight", 1.0m, 0m, 2m, 0.01m, "Weight applied to AI signals"),
new AdvisoryAiKnob("reachability_boost", 0.2m, 0m, 1m, 0.01m, "Boost when asset is reachable"),
new AdvisoryAiKnob("time_decay_half_life_days", 30m, 1m, 365m, 1m, "Half-life for decay"),
new AdvisoryAiKnob("evidence_freshness_threshold_hours", 72m, 1m, 720m, 1m, "Max evidence age")
};
}

View File

@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.BatchContext;
internal sealed record BatchContextRequest(
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("policy_profile_hash")] string PolicyProfileHash,
[property: JsonPropertyName("knobs_version")] string KnobsVersion,
[property: JsonPropertyName("overlay_hash")] string OverlayHash,
[property: JsonPropertyName("items")] IReadOnlyList<BatchContextItem> Items,
[property: JsonPropertyName("options")] BatchContextOptions Options);
internal sealed record BatchContextItem(
[property: JsonPropertyName("component_purl")] string ComponentPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId);
internal sealed record BatchContextOptions(
[property: JsonPropertyName("include_reachability")] bool IncludeReachability);
internal sealed record BatchContextResponse(
[property: JsonPropertyName("context_id")] string ContextId,
[property: JsonPropertyName("expires_at")] string ExpiresAt,
[property: JsonPropertyName("knobs_version")] string KnobsVersion,
[property: JsonPropertyName("overlay_hash")] string OverlayHash,
[property: JsonPropertyName("items")] IReadOnlyList<BatchContextResolvedItem> Items);
internal sealed record BatchContextResolvedItem(
[property: JsonPropertyName("component_purl")] string ComponentPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("trace_ref")] string TraceRef);

View File

@@ -0,0 +1,82 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Policy.Engine.BatchContext;
/// <summary>
/// Creates deterministic batch context responses for advisory AI (POLICY-ENGINE-31-002).
/// </summary>
internal sealed class BatchContextService
{
private readonly TimeProvider _timeProvider;
public BatchContextService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public BatchContextResponse Create(BatchContextRequest request)
{
if (request is null) throw new ArgumentNullException(nameof(request));
if (request.Items is null || request.Items.Count == 0)
{
throw new ArgumentException("items are required", nameof(request.Items));
}
var sortedItems = request.Items
.OrderBy(i => i.ComponentPurl, StringComparer.Ordinal)
.ThenBy(i => i.AdvisoryId, StringComparer.Ordinal)
.ToList();
var status = request.Options?.IncludeReachability == true ? "pending-reachability" : "pending";
var resolved = sortedItems
.Select(i => new BatchContextResolvedItem(
i.ComponentPurl,
i.AdvisoryId,
status,
ComputeTraceRef(request.TenantId, i)))
.ToList();
var expires = _timeProvider.GetUtcNow().AddHours(1).ToString("O");
var contextId = ComputeContextId(request, sortedItems);
return new BatchContextResponse(
contextId,
ExpiresAt: expires,
KnobsVersion: request.KnobsVersion,
OverlayHash: request.OverlayHash,
Items: resolved);
}
private static string ComputeContextId(BatchContextRequest request, IReadOnlyList<BatchContextItem> sortedItems)
{
var canonical = new
{
tenant = request.TenantId,
profile = request.PolicyProfileHash,
knobs = request.KnobsVersion,
overlay = request.OverlayHash,
items = sortedItems.Select(i => new { i.ComponentPurl, i.AdvisoryId }).ToArray(),
includeReachability = request.Options?.IncludeReachability ?? false
};
var json = JsonSerializer.Serialize(canonical, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
Span<byte> hash = stackalloc byte[16];
SHA256.HashData(Encoding.UTF8.GetBytes(json), hash);
return Convert.ToHexString(hash);
}
private static string ComputeTraceRef(string tenant, BatchContextItem item)
{
var stable = $"{tenant}|{item.ComponentPurl}|{item.AdvisoryId}";
Span<byte> hash = stackalloc byte[12];
SHA256.HashData(Encoding.UTF8.GetBytes(stable), hash);
return Convert.ToHexString(hash);
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.AdvisoryAI;
namespace StellaOps.Policy.Engine.Endpoints;
public static class AdvisoryAiKnobsEndpoint
{
public static IEndpointRouteBuilder MapAdvisoryAiKnobs(this IEndpointRouteBuilder routes)
{
routes.MapGet("/policy/advisory-ai/knobs", GetAsync)
.WithName("PolicyEngine.AdvisoryAI.Knobs.Get");
routes.MapPut("/policy/advisory-ai/knobs", PutAsync)
.WithName("PolicyEngine.AdvisoryAI.Knobs.Put");
return routes;
}
private static IResult GetAsync(AdvisoryAiKnobsService service)
{
var profile = service.Get();
return Results.Json(profile);
}
private static IResult PutAsync(
[FromBody] IReadOnlyList<AdvisoryAiKnob> knobs,
AdvisoryAiKnobsService service)
{
if (knobs is null || knobs.Count == 0)
{
return Results.BadRequest(new { message = "knobs are required" });
}
var profile = service.Set(knobs);
return Results.Json(profile);
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.BatchContext;
namespace StellaOps.Policy.Engine.Endpoints;
public static class BatchContextEndpoint
{
public static IEndpointRouteBuilder MapBatchContext(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/batch/context", HandleAsync)
.WithName("PolicyEngine.BatchContext.Create");
return routes;
}
private static IResult HandleAsync(
[FromBody] BatchContextRequest request,
BatchContextService service)
{
try
{
var response = service.Create(request);
return Results.Json(response);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { message = ex.Message });
}
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Ledger;
namespace StellaOps.Policy.Engine.Endpoints;
public static class LedgerExportEndpoint
{
public static IEndpointRouteBuilder MapLedgerExport(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/ledger/export", BuildAsync)
.WithName("PolicyEngine.Ledger.Export");
routes.MapGet("/policy/ledger/export/{exportId}", GetAsync)
.WithName("PolicyEngine.Ledger.GetExport");
return routes;
}
private static async Task<IResult> BuildAsync(
[FromBody] LedgerExportRequest request,
LedgerExportService service,
CancellationToken cancellationToken)
{
try
{
var export = await service.BuildAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Json(export);
}
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException)
{
return Results.BadRequest(new { message = ex.Message });
}
}
private static async Task<IResult> GetAsync(
[FromRoute] string exportId,
LedgerExportService service,
CancellationToken cancellationToken)
{
var export = await service.GetAsync(exportId, cancellationToken).ConfigureAwait(false);
return export is null ? Results.NotFound() : Results.Json(export);
}
}

View File

@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Orchestration;
namespace StellaOps.Policy.Engine.Endpoints;
public static class OrchestratorJobEndpoint
{
public static IEndpointRouteBuilder MapOrchestratorJobs(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/orchestrator/jobs", SubmitAsync)
.WithName("PolicyEngine.Orchestrator.Jobs.Submit");
routes.MapPost("/policy/orchestrator/jobs/preview", PreviewAsync)
.WithName("PolicyEngine.Orchestrator.Jobs.Preview");
routes.MapGet("/policy/orchestrator/jobs/{jobId}", GetAsync)
.WithName("PolicyEngine.Orchestrator.Jobs.Get");
return routes;
}
private static async Task<IResult> SubmitAsync(
[FromBody] OrchestratorJobRequest request,
OrchestratorJobService service,
CancellationToken cancellationToken)
{
try
{
var job = await service.SubmitAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Json(job);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { message = ex.Message });
}
}
private static async Task<IResult> PreviewAsync(
[FromBody] OrchestratorJobRequest request,
OrchestratorJobService service,
CancellationToken cancellationToken)
{
try
{
var job = await service.PreviewAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Json(job);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { message = ex.Message });
}
}
private static async Task<IResult> GetAsync(
[FromRoute] string jobId,
OrchestratorJobService service,
CancellationToken cancellationToken)
{
var job = await service.GetAsync(jobId, cancellationToken).ConfigureAwait(false);
return job is null ? Results.NotFound() : Results.Json(job);
}
}

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.Policy.Engine.Overlay;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -19,6 +20,7 @@ public static class PathScopeSimulationEndpoint
private static async Task<IResult> HandleAsync(
[FromBody] PathScopeSimulationRequest request,
PathScopeSimulationService service,
PathScopeSimulationBridgeService bridge,
CancellationToken cancellationToken)
{
try
@@ -31,6 +33,19 @@ public static class PathScopeSimulationEndpoint
responseBuilder.AppendLine(line);
}
// Emit change event stub when run in what-if mode.
if (request.Options.Deterministic && request.Options.IncludeTrace)
{
var bridgeRequest = new PathScopeSimulationBridgeRequest(
Tenant: request.Tenant,
Rules: Array.Empty<string>(),
Overlays: null,
Paths: new[] { request },
Mode: "preview",
Seed: null);
await bridge.SimulateAsync(bridgeRequest, cancellationToken).ConfigureAwait(false);
}
return Results.Text(responseBuilder.ToString(), "application/x-ndjson", Encoding.UTF8);
}
catch (PathScopeSimulationException ex)

View File

@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Orchestration;
namespace StellaOps.Policy.Engine.Endpoints;
public static class PolicyWorkerEndpoint
{
public static IEndpointRouteBuilder MapPolicyWorker(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/worker/run", RunAsync)
.WithName("PolicyEngine.Worker.Run");
routes.MapGet("/policy/worker/jobs/{jobId}", GetResultAsync)
.WithName("PolicyEngine.Worker.GetResult");
return routes;
}
private static async Task<IResult> RunAsync(
[FromBody] WorkerRunRequest request,
PolicyWorkerService service,
CancellationToken cancellationToken)
{
try
{
var result = await service.ExecuteAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Json(result);
}
catch (KeyNotFoundException ex)
{
return Results.NotFound(new { message = ex.Message });
}
}
private static async Task<IResult> GetResultAsync(
[FromRoute] string jobId,
IWorkerResultStore store,
CancellationToken cancellationToken)
{
var result = await store.GetByJobIdAsync(jobId, cancellationToken).ConfigureAwait(false);
return result is null ? Results.NotFound() : Results.Json(result);
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Snapshots;
namespace StellaOps.Policy.Engine.Endpoints;
public static class SnapshotEndpoint
{
public static IEndpointRouteBuilder MapSnapshots(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/snapshots", CreateAsync)
.WithName("PolicyEngine.Snapshots.Create");
routes.MapGet("/policy/snapshots", ListAsync)
.WithName("PolicyEngine.Snapshots.List");
routes.MapGet("/policy/snapshots/{snapshotId}", GetAsync)
.WithName("PolicyEngine.Snapshots.Get");
return routes;
}
private static async Task<IResult> CreateAsync(
[FromBody] SnapshotRequest request,
SnapshotService service,
CancellationToken cancellationToken)
{
try
{
var snapshot = await service.CreateAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Json(snapshot);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { message = ex.Message });
}
}
private static async Task<IResult> ListAsync(
[FromQuery(Name = "tenant_id")] string? tenantId,
SnapshotService service,
CancellationToken cancellationToken)
{
var (items, cursor) = await service.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
return Results.Json(new { items, next_cursor = cursor });
}
private static async Task<IResult> GetAsync(
[FromRoute] string snapshotId,
SnapshotService service,
CancellationToken cancellationToken)
{
var snapshot = await service.GetAsync(snapshotId, cancellationToken).ConfigureAwait(false);
return snapshot is null ? Results.NotFound() : Results.Json(snapshot);
}
}

View File

@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.TrustWeighting;
namespace StellaOps.Policy.Engine.Endpoints;
public static class TrustWeightingEndpoint
{
public static IEndpointRouteBuilder MapTrustWeighting(this IEndpointRouteBuilder routes)
{
routes.MapGet("/policy/trust-weighting", GetAsync)
.WithName("PolicyEngine.TrustWeighting.Get");
routes.MapPut("/policy/trust-weighting", PutAsync)
.WithName("PolicyEngine.TrustWeighting.Put");
routes.MapGet("/policy/trust-weighting/preview", PreviewAsync)
.WithName("PolicyEngine.TrustWeighting.Preview");
return routes;
}
private static IResult GetAsync(TrustWeightingService service)
{
var profile = service.Get();
return Results.Json(profile);
}
private static IResult PutAsync(
[FromBody] IReadOnlyList<TrustWeightingEntry> weights,
TrustWeightingService service)
{
if (weights is null || weights.Count == 0)
{
return Results.BadRequest(new { message = "weights are required" });
}
var profile = service.Set(weights);
return Results.Json(profile);
}
private static IResult PreviewAsync(
[FromQuery(Name = "overlay_hash")] string? overlayHash,
TrustWeightingService service)
{
var profile = service.Get();
var preview = new
{
weights = profile.Weights,
profile_hash = profile.ProfileHash,
overlay_hash = overlayHash,
mode = "preview"
};
return Results.Json(preview);
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Violations;
namespace StellaOps.Policy.Engine.Endpoints;
public static class ViolationEndpoint
{
public static IEndpointRouteBuilder MapViolations(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/violations/events", EmitEventsAsync)
.WithName("PolicyEngine.Violations.Events");
routes.MapPost("/policy/violations/severity", FuseAsync)
.WithName("PolicyEngine.Violations.Severity");
routes.MapPost("/policy/violations/conflicts", ConflictsAsync)
.WithName("PolicyEngine.Violations.Conflicts");
return routes;
}
private static async Task<IResult> EmitEventsAsync(
[FromBody] ViolationEventRequest request,
ViolationEventService service,
CancellationToken cancellationToken)
{
try
{
var events = await service.EmitAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Json(new { events });
}
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException)
{
return Results.BadRequest(new { message = ex.Message });
}
}
private static async Task<IResult> FuseAsync(
[FromBody] ViolationEventRequest request,
ViolationEventService eventService,
SeverityFusionService fusionService,
CancellationToken cancellationToken)
{
try
{
await eventService.EmitAsync(request, cancellationToken).ConfigureAwait(false);
var fused = await fusionService.FuseAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false);
return Results.Json(new { fused });
}
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException)
{
return Results.BadRequest(new { message = ex.Message });
}
}
private static async Task<IResult> ConflictsAsync(
[FromBody] ConflictRequest request,
ViolationEventService eventService,
SeverityFusionService fusionService,
ConflictHandlingService conflictService,
CancellationToken cancellationToken)
{
try
{
await eventService.EmitAsync(new ViolationEventRequest(request.SnapshotId), cancellationToken).ConfigureAwait(false);
var fused = await fusionService.FuseAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false);
var conflicts = await conflictService.ComputeAsync(request.SnapshotId, fused, cancellationToken).ConfigureAwait(false);
return Results.Json(new { conflicts });
}
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException)
{
return Results.BadRequest(new { message = ex.Message });
}
}
}

View File

@@ -0,0 +1,103 @@
using System.Text.Json;
using StellaOps.Policy.Engine.Orchestration;
namespace StellaOps.Policy.Engine.Ledger;
/// <summary>
/// Builds deterministic NDJSON ledger exports from worker results (POLICY-ENGINE-34-101).
/// </summary>
internal sealed class LedgerExportService
{
private const string SchemaVersion = "policy-ledger-export-v1";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly TimeProvider _timeProvider;
private readonly IOrchestratorJobStore _jobs;
private readonly IWorkerResultStore _results;
private readonly ILedgerExportStore _store;
public LedgerExportService(
TimeProvider timeProvider,
IOrchestratorJobStore jobs,
IWorkerResultStore results,
ILedgerExportStore store)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
_results = results ?? throw new ArgumentNullException(nameof(results));
_store = store ?? throw new ArgumentNullException(nameof(store));
}
public async Task<LedgerExport> BuildAsync(LedgerExportRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var jobs = await _jobs.ListAsync(request.TenantId, cancellationToken).ConfigureAwait(false);
var completed = jobs.Where(j => string.Equals(j.Status, "completed", StringComparison.Ordinal)).ToList();
var records = new List<LedgerExportRecord>();
foreach (var job in completed)
{
var result = await _results.GetByJobIdAsync(job.JobId, cancellationToken).ConfigureAwait(false);
if (result is null)
{
continue;
}
foreach (var item in result.Results)
{
records.Add(new LedgerExportRecord(
TenantId: job.TenantId,
JobId: job.JobId,
ContextId: job.ContextId,
ComponentPurl: item.ComponentPurl,
AdvisoryId: item.AdvisoryId,
Status: item.Status,
TraceRef: item.TraceRef,
OccurredAt: result.CompletedAt.ToString("O")));
}
}
var ordered = records
.OrderBy(r => r.TenantId, StringComparer.Ordinal)
.ThenBy(r => r.JobId, StringComparer.Ordinal)
.ThenBy(r => r.ComponentPurl, StringComparer.Ordinal)
.ThenBy(r => r.AdvisoryId, StringComparer.Ordinal)
.ToList();
var generatedAt = _timeProvider.GetUtcNow().ToString("O");
var exportId = StableIdGenerator.CreateUlid($"{request.TenantId}|{generatedAt}|{ordered.Count}");
var recordLines = ordered.Select(r => JsonSerializer.Serialize(r, SerializerOptions)).ToList();
var sha = StableIdGenerator.Sha256Hex(string.Join('\n', recordLines));
var manifest = new LedgerExportManifest(
ExportId: exportId,
SchemaVersion: SchemaVersion,
GeneratedAt: generatedAt,
RecordCount: ordered.Count,
Sha256: sha);
var lines = new List<string>(recordLines.Count + 1)
{
JsonSerializer.Serialize(manifest, SerializerOptions)
};
lines.AddRange(recordLines);
var export = new LedgerExport(manifest, ordered, lines);
await _store.SaveAsync(export, cancellationToken).ConfigureAwait(false);
return export;
}
public Task<LedgerExport?> GetAsync(string exportId, CancellationToken cancellationToken = default)
{
return _store.GetAsync(exportId, cancellationToken);
}
public Task<IReadOnlyList<LedgerExport>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
return _store.ListAsync(tenantId, cancellationToken);
}
}

View File

@@ -0,0 +1,44 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.Ledger;
internal interface ILedgerExportStore
{
Task SaveAsync(LedgerExport export, CancellationToken cancellationToken = default);
Task<LedgerExport?> GetAsync(string exportId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<LedgerExport>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
}
internal sealed class InMemoryLedgerExportStore : ILedgerExportStore
{
private readonly ConcurrentDictionary<string, LedgerExport> _exports = new(StringComparer.Ordinal);
public Task SaveAsync(LedgerExport export, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(export);
_exports[export.Manifest.ExportId] = export;
return Task.CompletedTask;
}
public Task<LedgerExport?> GetAsync(string exportId, CancellationToken cancellationToken = default)
{
_exports.TryGetValue(exportId, out var value);
return Task.FromResult(value);
}
public Task<IReadOnlyList<LedgerExport>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<LedgerExport> exports = _exports.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
exports = exports.Where(x => x.Records.Any(r => string.Equals(r.TenantId, tenantId, StringComparison.Ordinal)));
}
var ordered = exports
.OrderBy(e => e.Manifest.GeneratedAt, StringComparer.Ordinal)
.ThenBy(e => e.Manifest.ExportId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<LedgerExport>>(ordered);
}
}

View File

@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Ledger;
internal sealed record LedgerExportRecord(
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("context_id")] string ContextId,
[property: JsonPropertyName("component_purl")] string ComponentPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("trace_ref")] string TraceRef,
[property: JsonPropertyName("occurred_at")] string OccurredAt);
internal sealed record LedgerExportManifest(
[property: JsonPropertyName("export_id")] string ExportId,
[property: JsonPropertyName("schema_version")] string SchemaVersion,
[property: JsonPropertyName("generated_at")] string GeneratedAt,
[property: JsonPropertyName("record_count")] int RecordCount,
[property: JsonPropertyName("sha256")] string Sha256);
internal sealed record LedgerExport(
LedgerExportManifest Manifest,
IReadOnlyList<LedgerExportRecord> Records,
IReadOnlyList<string> Lines);
internal sealed record LedgerExportRequest(
[property: JsonPropertyName("tenant_id")] string TenantId);

View File

@@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Orchestration;
internal sealed record OrchestratorJobItem(
[property: JsonPropertyName("component_purl")] string ComponentPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId);
internal sealed record OrchestratorJobCallbacks(
[property: JsonPropertyName("sse")] string? Sse,
[property: JsonPropertyName("nats")] string? Nats);
internal sealed record OrchestratorJobRequest(
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("context_id")] string ContextId,
[property: JsonPropertyName("policy_profile_hash")] string PolicyProfileHash,
[property: JsonPropertyName("batch_items")] IReadOnlyList<OrchestratorJobItem> BatchItems,
[property: JsonPropertyName("priority")] string Priority = "normal",
[property: JsonPropertyName("trace_ref")] string? TraceRef = null,
[property: JsonPropertyName("callbacks")] OrchestratorJobCallbacks? Callbacks = null,
[property: JsonPropertyName("requested_at")] DateTimeOffset? RequestedAt = null);
internal sealed record OrchestratorJob(
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("context_id")] string ContextId,
[property: JsonPropertyName("policy_profile_hash")] string PolicyProfileHash,
[property: JsonPropertyName("requested_at")] DateTimeOffset RequestedAt,
[property: JsonPropertyName("priority")] string Priority,
[property: JsonPropertyName("batch_items")] IReadOnlyList<OrchestratorJobItem> BatchItems,
[property: JsonPropertyName("callbacks")] OrchestratorJobCallbacks? Callbacks,
[property: JsonPropertyName("trace_ref")] string TraceRef,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("determinism_hash")] string DeterminismHash,
[property: JsonPropertyName("completed_at")] DateTimeOffset? CompletedAt = null,
[property: JsonPropertyName("result_hash")] string? ResultHash = null);

View File

@@ -0,0 +1,112 @@
using System.Text;
namespace StellaOps.Policy.Engine.Orchestration;
internal sealed class OrchestratorJobService
{
private static readonly string[] AllowedPriorities = { "normal", "high", "emergency" };
private readonly TimeProvider _timeProvider;
private readonly IOrchestratorJobStore _store;
public OrchestratorJobService(TimeProvider timeProvider, IOrchestratorJobStore store)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_store = store ?? throw new ArgumentNullException(nameof(store));
}
public async Task<OrchestratorJob> SubmitAsync(
OrchestratorJobRequest request,
CancellationToken cancellationToken = default)
{
var job = BuildJob(request);
await _store.SaveAsync(job, cancellationToken).ConfigureAwait(false);
return job;
}
public Task<OrchestratorJob> PreviewAsync(OrchestratorJobRequest request, CancellationToken cancellationToken = default)
{
var job = BuildJob(request, preview: true);
return Task.FromResult(job);
}
public Task<OrchestratorJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
{
return _store.GetAsync(jobId, cancellationToken);
}
private OrchestratorJob BuildJob(OrchestratorJobRequest request, bool preview = false)
{
ArgumentNullException.ThrowIfNull(request);
if (request.BatchItems is null || request.BatchItems.Count == 0)
{
throw new ArgumentException("batch_items are required", nameof(request));
}
var normalizedPriority = NormalizePriority(request.Priority);
var requestedAt = request.RequestedAt ?? _timeProvider.GetUtcNow();
var orderedItems = request.BatchItems
.OrderBy(i => i.ComponentPurl, StringComparer.Ordinal)
.ThenBy(i => i.AdvisoryId, StringComparer.Ordinal)
.ToList();
var seed = $"{request.TenantId}|{request.ContextId}|{requestedAt:O}";
var jobId = StableIdGenerator.CreateUlid(seed);
var traceRef = request.TraceRef ?? StableIdGenerator.Sha256Hex($"{jobId}|trace");
var determinismHash = StableIdGenerator.Sha256Hex(BuildDeterminismSeed(request, orderedItems, requestedAt, normalizedPriority));
var status = preview ? "preview" : "queued";
return new OrchestratorJob(
JobId: jobId,
TenantId: request.TenantId,
ContextId: request.ContextId,
PolicyProfileHash: request.PolicyProfileHash,
RequestedAt: requestedAt,
Priority: normalizedPriority,
BatchItems: orderedItems,
Callbacks: request.Callbacks,
TraceRef: traceRef,
Status: status,
DeterminismHash: determinismHash);
}
private static string NormalizePriority(string? value)
{
var normalized = (value ?? "normal").Trim().ToLowerInvariant();
if (!AllowedPriorities.Contains(normalized))
{
normalized = "normal";
}
return normalized;
}
private static string BuildDeterminismSeed(
OrchestratorJobRequest request,
IReadOnlyList<OrchestratorJobItem> items,
DateTimeOffset requestedAt,
string priority)
{
var builder = new StringBuilder();
builder.Append(request.TenantId).Append('|')
.Append(request.ContextId).Append('|')
.Append(request.PolicyProfileHash).Append('|')
.Append(priority).Append('|')
.Append(requestedAt.ToString("O"));
foreach (var item in items)
{
builder.Append('|').Append(item.ComponentPurl).Append('|').Append(item.AdvisoryId);
}
if (request.Callbacks is not null)
{
builder.Append('|').Append(request.Callbacks.Sse).Append('|').Append(request.Callbacks.Nats);
}
return builder.ToString();
}
}

View File

@@ -0,0 +1,58 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.Orchestration;
internal interface IOrchestratorJobStore
{
Task SaveAsync(OrchestratorJob job, CancellationToken cancellationToken = default);
Task<OrchestratorJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OrchestratorJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
Task UpdateAsync(string jobId, Func<OrchestratorJob, OrchestratorJob> update, CancellationToken cancellationToken = default);
}
internal sealed class InMemoryOrchestratorJobStore : IOrchestratorJobStore
{
private readonly ConcurrentDictionary<string, OrchestratorJob> _jobs = new(StringComparer.Ordinal);
public Task SaveAsync(OrchestratorJob job, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
_jobs[job.JobId] = job;
return Task.CompletedTask;
}
public Task<OrchestratorJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
{
_jobs.TryGetValue(jobId, out var job);
return Task.FromResult(job);
}
public Task<IReadOnlyList<OrchestratorJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<OrchestratorJob> items = _jobs.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
items = items.Where(j => string.Equals(j.TenantId, tenantId, StringComparison.Ordinal));
}
var ordered = items
.OrderBy(j => j.TenantId, StringComparer.Ordinal)
.ThenBy(j => j.RequestedAt)
.ThenBy(j => j.JobId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<OrchestratorJob>>(ordered);
}
public Task UpdateAsync(string jobId, Func<OrchestratorJob, OrchestratorJob> update, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(update);
_jobs.AddOrUpdate(
jobId,
_ => throw new KeyNotFoundException($"Job {jobId} not found"),
(_, existing) => update(existing));
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Orchestration;
internal sealed record WorkerResultItem(
[property: JsonPropertyName("component_purl")] string ComponentPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("trace_ref")] string TraceRef);
internal sealed record WorkerRunResult(
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("worker_id")] string WorkerId,
[property: JsonPropertyName("started_at")] DateTimeOffset StartedAt,
[property: JsonPropertyName("completed_at")] DateTimeOffset CompletedAt,
[property: JsonPropertyName("results")] IReadOnlyList<WorkerResultItem> Results,
[property: JsonPropertyName("result_hash")] string ResultHash);
internal sealed record WorkerRunRequest(
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("worker_id")] string? WorkerId = null);

View File

@@ -0,0 +1,107 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Policy.Engine.Orchestration;
/// <summary>
/// Deterministic worker stub for POLICY-ENGINE-33-101. Consumes orchestrator jobs and
/// produces stable result hashes so retries can short-circuit.
/// </summary>
internal sealed class PolicyWorkerService
{
private readonly TimeProvider _timeProvider;
private readonly IOrchestratorJobStore _jobs;
private readonly IWorkerResultStore _results;
public PolicyWorkerService(
TimeProvider timeProvider,
IOrchestratorJobStore jobs,
IWorkerResultStore results)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
_results = results ?? throw new ArgumentNullException(nameof(results));
}
public async Task<WorkerRunResult> ExecuteAsync(
WorkerRunRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var job = await _jobs.GetAsync(request.JobId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Job {request.JobId} not found");
var existing = await _results.GetByJobIdAsync(job.JobId, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
return existing;
}
var workerId = string.IsNullOrWhiteSpace(request.WorkerId) ? "worker-stub" : request.WorkerId;
var startedAt = _timeProvider.GetUtcNow();
var results = BuildResults(job);
var completedAt = _timeProvider.GetUtcNow();
var resultHash = StableIdGenerator.Sha256Hex(BuildSeed(job.JobId, results));
var runResult = new WorkerRunResult(
JobId: job.JobId,
WorkerId: workerId,
StartedAt: startedAt,
CompletedAt: completedAt,
Results: results,
ResultHash: resultHash);
await _results.SaveAsync(runResult, cancellationToken).ConfigureAwait(false);
await _jobs.UpdateAsync(job.JobId, j => j with { Status = "completed", CompletedAt = completedAt, ResultHash = resultHash }, cancellationToken)
.ConfigureAwait(false);
return runResult;
}
private static IReadOnlyList<WorkerResultItem> BuildResults(OrchestratorJob job)
{
var builder = new List<WorkerResultItem>(job.BatchItems.Count);
foreach (var item in job.BatchItems)
{
var hash = BuildItemHash(job.JobId, item);
var status = (hash % 3) switch
{
0 => "violation",
1 => "warn",
_ => "ok"
};
var traceRef = StableIdGenerator.Sha256Hex($"{job.JobId}|{item.ComponentPurl}|{item.AdvisoryId}");
builder.Add(new WorkerResultItem(item.ComponentPurl, item.AdvisoryId, status, traceRef));
}
return builder
.OrderBy(r => r.ComponentPurl, StringComparer.Ordinal)
.ThenBy(r => r.AdvisoryId, StringComparer.Ordinal)
.ToList();
}
private static int BuildItemHash(string jobId, OrchestratorJobItem item)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes($"{jobId}|{item.ComponentPurl}|{item.AdvisoryId}"), hash);
return BitConverter.ToInt32(hash[..4]);
}
private static string BuildSeed(string jobId, IReadOnlyList<WorkerResultItem> results)
{
var sb = new StringBuilder(jobId);
foreach (var result in results)
{
sb.Append('|').Append(result.ComponentPurl)
.Append('|').Append(result.AdvisoryId)
.Append('|').Append(result.Status);
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,61 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Policy.Engine.Orchestration;
internal static class StableIdGenerator
{
private const string Base32Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
public static string CreateUlid(string seed)
{
if (string.IsNullOrWhiteSpace(seed))
{
throw new ArgumentException("Seed is required", nameof(seed));
}
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(seed), hash);
Span<char> buffer = stackalloc char[26];
EncodeBase32(hash[..16], buffer);
return new string(buffer);
}
public static string Sha256Hex(string value)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(value), hash);
return Convert.ToHexString(hash);
}
private static void EncodeBase32(ReadOnlySpan<byte> input, Span<char> output)
{
int buffer = 0;
int bitsLeft = 0;
int index = 0;
foreach (var b in input)
{
buffer = (buffer << 8) | b;
bitsLeft += 8;
while (bitsLeft >= 5 && index < output.Length)
{
var value = (buffer >> (bitsLeft - 5)) & 31;
output[index++] = Base32Alphabet[value];
bitsLeft -= 5;
}
}
if (index < output.Length)
{
output[index++] = Base32Alphabet[(buffer << (5 - bitsLeft)) & 31];
}
while (index < output.Length)
{
output[index++] = Base32Alphabet[0];
}
}
}

View File

@@ -0,0 +1,52 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.Orchestration;
internal interface IWorkerResultStore
{
Task SaveAsync(WorkerRunResult result, CancellationToken cancellationToken = default);
Task<WorkerRunResult?> GetByJobIdAsync(string jobId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<WorkerRunResult>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
}
internal sealed class InMemoryWorkerResultStore : IWorkerResultStore
{
private readonly ConcurrentDictionary<string, WorkerRunResult> _results = new(StringComparer.Ordinal);
private readonly IOrchestratorJobStore _jobs;
public InMemoryWorkerResultStore(IOrchestratorJobStore jobs)
{
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
}
public Task SaveAsync(WorkerRunResult result, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
_results[result.JobId] = result;
return Task.CompletedTask;
}
public Task<WorkerRunResult?> GetByJobIdAsync(string jobId, CancellationToken cancellationToken = default)
{
_results.TryGetValue(jobId, out var value);
return Task.FromResult(value);
}
public async Task<IReadOnlyList<WorkerRunResult>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return _results.Values.OrderBy(r => r.JobId, StringComparer.Ordinal).ToList();
}
var jobs = await _jobs.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var jobIds = jobs.Select(j => j.JobId).ToHashSet(StringComparer.Ordinal);
var filtered = _results.Values
.Where(r => jobIds.Contains(r.JobId))
.OrderBy(r => r.JobId, StringComparer.Ordinal)
.ToList();
return filtered;
}
}

View File

@@ -165,7 +165,7 @@ internal sealed class PathScopeSimulationBridgeService
private static string BuildCorrelationId(PathScopeSimulationBridgeRequest request)
{
var stable = $"{request.Tenant}|{request.Mode}|{request.Seed ?? DefaultSeed}";
Span<byte> hash = stackalloc byte[16];
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(stable), hash);
return Convert.ToHexString(hash);
}

View File

@@ -5,15 +5,16 @@ using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Policy.Engine.Hosting;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Hosting;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Endpoints;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.AirGap.Policy;
using StellaOps.Policy.Engine.Orchestration;
var builder = WebApplication.CreateBuilder(args);
var policyEngineConfigFiles = new[]
@@ -115,12 +116,27 @@ builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionS
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.IOverlayEventSink, StellaOps.Policy.Engine.Overlay.LoggingOverlayEventSink>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayChangeEventPublisher>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulationBridgeService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.TrustWeighting.TrustWeightingService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.AdvisoryAI.AdvisoryAiKnobsService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.BatchContext.BatchContextService>();
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddSingleton<IOrchestratorJobStore, InMemoryOrchestratorJobStore>();
builder.Services.AddSingleton<OrchestratorJobService>();
builder.Services.AddSingleton<IWorkerResultStore, InMemoryWorkerResultStore>();
builder.Services.AddSingleton<PolicyWorkerService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.ILedgerExportStore, StellaOps.Policy.Engine.Ledger.InMemoryLedgerExportStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.LedgerExportService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ViolationEventService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.SeverityFusionService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ConflictHandlingService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
@@ -164,5 +180,13 @@ app.MapPolicyCompilation();
app.MapPolicyPacks();
app.MapPathScopeSimulation();
app.MapOverlaySimulation();
app.MapTrustWeighting();
app.MapAdvisoryAiKnobs();
app.MapBatchContext();
app.MapOrchestratorJobs();
app.MapPolicyWorker();
app.MapLedgerExport();
app.MapSnapshots();
app.MapViolations();
app.Run();

View File

@@ -139,7 +139,7 @@ internal sealed partial class PolicyEvaluationService
private static string ComputeCorrelationId(string stableKey)
{
Span<byte> hashBytes = stackalloc byte[16];
Span<byte> hashBytes = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(stableKey), hashBytes);
return Convert.ToHexString(hashBytes);
}

View File

@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
using StellaOps.Policy.Engine.Ledger;
namespace StellaOps.Policy.Engine.Snapshots;
internal sealed record SnapshotSummary(
[property: JsonPropertyName("snapshot_id")] string SnapshotId,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("ledger_export_id")] string LedgerExportId,
[property: JsonPropertyName("generated_at")] string GeneratedAt,
[property: JsonPropertyName("status_counts")] IReadOnlyDictionary<string, int> StatusCounts);
internal sealed record SnapshotDetail(
[property: JsonPropertyName("snapshot_id")] string SnapshotId,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("ledger_export_id")] string LedgerExportId,
[property: JsonPropertyName("generated_at")] string GeneratedAt,
[property: JsonPropertyName("overlay_hash")] string OverlayHash,
[property: JsonPropertyName("status_counts")] IReadOnlyDictionary<string, int> StatusCounts,
[property: JsonPropertyName("records")] IReadOnlyList<LedgerExportRecord> Records);
internal sealed record SnapshotRequest(
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("overlay_hash")] string OverlayHash);

View File

@@ -0,0 +1,76 @@
using StellaOps.Policy.Engine.Ledger;
using StellaOps.Policy.Engine.Orchestration;
namespace StellaOps.Policy.Engine.Snapshots;
/// <summary>
/// Snapshot API stub (POLICY-ENGINE-35-201) built on ledger exports.
/// </summary>
internal sealed class SnapshotService
{
private readonly TimeProvider _timeProvider;
private readonly LedgerExportService _ledger;
private readonly ISnapshotStore _store;
public SnapshotService(
TimeProvider timeProvider,
LedgerExportService ledger,
ISnapshotStore store)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_ledger = ledger ?? throw new ArgumentNullException(nameof(ledger));
_store = store ?? throw new ArgumentNullException(nameof(store));
}
public async Task<SnapshotDetail> CreateAsync(
SnapshotRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var exports = await _ledger.ListAsync(request.TenantId, cancellationToken).ConfigureAwait(false);
var export = exports.LastOrDefault();
if (export is null)
{
export = await _ledger.BuildAsync(new LedgerExportRequest(request.TenantId), cancellationToken)
.ConfigureAwait(false);
}
var statusCounts = export.Records
.GroupBy(r => r.Status)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
var generatedAt = _timeProvider.GetUtcNow().ToString("O");
var snapshotId = StableIdGenerator.CreateUlid($"{export.Manifest.ExportId}|{request.OverlayHash}");
var snapshot = new SnapshotDetail(
SnapshotId: snapshotId,
TenantId: request.TenantId,
LedgerExportId: export.Manifest.ExportId,
GeneratedAt: generatedAt,
OverlayHash: request.OverlayHash,
StatusCounts: statusCounts,
Records: export.Records);
await _store.SaveAsync(snapshot, cancellationToken).ConfigureAwait(false);
return snapshot;
}
public Task<SnapshotDetail?> GetAsync(string snapshotId, CancellationToken cancellationToken = default)
{
return _store.GetAsync(snapshotId, cancellationToken);
}
public async Task<(IReadOnlyList<SnapshotSummary> Items, string? NextCursor)> ListAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var snapshots = await _store.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var summaries = snapshots
.OrderByDescending(s => s.GeneratedAt, StringComparer.Ordinal)
.Select(s => new SnapshotSummary(s.SnapshotId, s.TenantId, s.LedgerExportId, s.GeneratedAt, s.StatusCounts))
.ToList();
return (summaries, null);
}
}

View File

@@ -0,0 +1,44 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.Snapshots;
internal interface ISnapshotStore
{
Task SaveAsync(SnapshotDetail snapshot, CancellationToken cancellationToken = default);
Task<SnapshotDetail?> GetAsync(string snapshotId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<SnapshotDetail>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
}
internal sealed class InMemorySnapshotStore : ISnapshotStore
{
private readonly ConcurrentDictionary<string, SnapshotDetail> _snapshots = new(StringComparer.Ordinal);
public Task SaveAsync(SnapshotDetail snapshot, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(snapshot);
_snapshots[snapshot.SnapshotId] = snapshot;
return Task.CompletedTask;
}
public Task<SnapshotDetail?> GetAsync(string snapshotId, CancellationToken cancellationToken = default)
{
_snapshots.TryGetValue(snapshotId, out var value);
return Task.FromResult(value);
}
public Task<IReadOnlyList<SnapshotDetail>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<SnapshotDetail> items = _snapshots.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
items = items.Where(s => string.Equals(s.TenantId, tenantId, StringComparison.Ordinal));
}
var ordered = items
.OrderBy(s => s.GeneratedAt, StringComparer.Ordinal)
.ThenBy(s => s.SnapshotId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<SnapshotDetail>>(ordered);
}
}

View File

@@ -59,6 +59,11 @@ internal sealed class PathScopeSimulationService
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("subject.purl or subject.cpe is required"));
}
if (!string.Equals(request.Options.Sort, "path,finding,verdict", StringComparison.Ordinal))
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("options.sort must be 'path,finding,verdict'"));
}
foreach (var target in request.Targets)
{
if (string.IsNullOrWhiteSpace(target.FilePath))

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.TrustWeighting;
internal sealed record TrustWeightingEntry(
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("weight")] decimal Weight,
[property: JsonPropertyName("justification")] string? Justification,
[property: JsonPropertyName("updated_at")] string UpdatedAt);
internal sealed record TrustWeightingProfile(
[property: JsonPropertyName("weights")] IReadOnlyList<TrustWeightingEntry> Weights,
[property: JsonPropertyName("profile_hash")] string ProfileHash);

View File

@@ -0,0 +1,77 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Policy.Engine.TrustWeighting;
/// <summary>
/// In-memory trust weighting profile store (stub for POLICY-ENGINE-30-101).
/// Deterministic ordering and hashing.
/// </summary>
internal sealed class TrustWeightingService
{
private readonly TimeProvider _timeProvider;
private readonly object _lock = new();
private TrustWeightingProfile _current;
public TrustWeightingService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_current = BuildProfile(DefaultWeights());
}
public TrustWeightingProfile Get() => _current;
public TrustWeightingProfile Set(IReadOnlyList<TrustWeightingEntry> entries)
{
var normalized = Normalize(entries);
var profile = BuildProfile(normalized);
lock (_lock)
{
_current = profile;
}
return profile;
}
private TrustWeightingProfile BuildProfile(IReadOnlyList<TrustWeightingEntry> weights)
{
var json = JsonSerializer.Serialize(weights, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
return new TrustWeightingProfile(weights, hash);
}
private IReadOnlyList<TrustWeightingEntry> Normalize(IReadOnlyList<TrustWeightingEntry> entries)
{
var now = _timeProvider.GetUtcNow().ToString("O");
var normalized = entries
.Where(e => !string.IsNullOrWhiteSpace(e.Source))
.Select(e => new TrustWeightingEntry(
Source: e.Source.Trim().ToLowerInvariant(),
Weight: Math.Round(e.Weight, 3, MidpointRounding.ToZero),
Justification: string.IsNullOrWhiteSpace(e.Justification) ? null : e.Justification.Trim(),
UpdatedAt: string.IsNullOrWhiteSpace(e.UpdatedAt) ? now : e.UpdatedAt))
.OrderBy(e => e.Source, StringComparer.Ordinal)
.ToList();
return normalized;
}
private static IReadOnlyList<TrustWeightingEntry> DefaultWeights()
{
var now = TimeProvider.System.GetUtcNow().ToString("O");
return new[]
{
new TrustWeightingEntry("cartographer", 1.000m, null, now),
new TrustWeightingEntry("concelier", 1.000m, null, now),
new TrustWeightingEntry("scanner", 1.000m, null, now),
new TrustWeightingEntry("advisory_ai", 1.000m, null, now)
};
}
}

View File

@@ -0,0 +1,42 @@
namespace StellaOps.Policy.Engine.Violations;
/// <summary>
/// Conflict detection over fused severities (POLICY-ENGINE-40-002).
/// </summary>
internal sealed class ConflictHandlingService
{
private readonly IViolationEventStore _store;
public ConflictHandlingService(IViolationEventStore store)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
}
public async Task<IReadOnlyList<ConflictRecord>> ComputeAsync(string snapshotId, IReadOnlyList<SeverityFusionResult>? fused = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(snapshotId))
{
throw new ArgumentException("snapshot_id is required", nameof(snapshotId));
}
var source = fused ?? await _store.GetFusionAsync(snapshotId, cancellationToken).ConfigureAwait(false);
var conflicts = new List<ConflictRecord>();
var grouped = source
.GroupBy(r => (r.ComponentPurl, r.AdvisoryId, r.TenantId))
.Where(g => g.Select(x => x.SeverityFused).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1);
foreach (var group in grouped)
{
conflicts.Add(new ConflictRecord(
TenantId: group.Key.TenantId,
ComponentPurl: group.Key.ComponentPurl,
AdvisoryId: group.Key.AdvisoryId,
Conflicts: group.ToList(),
ResolvedStatus: null));
}
await _store.SaveConflictsAsync(snapshotId, conflicts, cancellationToken).ConfigureAwait(false);
return conflicts;
}
}

View File

@@ -0,0 +1,87 @@
using StellaOps.Policy.Engine.TrustWeighting;
namespace StellaOps.Policy.Engine.Violations;
/// <summary>
/// Deterministic severity fusion (POLICY-ENGINE-40-001).
/// </summary>
internal sealed class SeverityFusionService
{
private readonly IViolationEventStore _store;
private readonly TrustWeightingService _weights;
public SeverityFusionService(IViolationEventStore store, TrustWeightingService weights)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_weights = weights ?? throw new ArgumentNullException(nameof(weights));
}
public async Task<IReadOnlyList<SeverityFusionResult>> FuseAsync(string snapshotId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(snapshotId))
{
throw new ArgumentException("snapshot_id is required", nameof(snapshotId));
}
var existing = await _store.GetFusionAsync(snapshotId, cancellationToken).ConfigureAwait(false);
if (existing.Count > 0)
{
return existing;
}
var events = await _store.GetEventsAsync(snapshotId, cancellationToken).ConfigureAwait(false);
if (events.Count == 0)
{
return Array.Empty<SeverityFusionResult>();
}
var weights = _weights.Get();
var defaultWeight = weights.Weights.FirstOrDefault(w => string.Equals(w.Source, "policy-engine", StringComparison.OrdinalIgnoreCase))?.Weight ?? 1.0m;
var results = new List<SeverityFusionResult>(events.Count);
foreach (var ev in events.OrderBy(e => e.ComponentPurl, StringComparer.Ordinal)
.ThenBy(e => e.AdvisoryId, StringComparer.Ordinal))
{
var baseScore = SeverityToScore(ev.Severity);
var weightedScore = Math.Round(baseScore * defaultWeight, 3, MidpointRounding.ToZero);
var fusedSeverity = ScoreToLabel(weightedScore);
var sources = new List<SeveritySource>
{
new("policy-engine", defaultWeight, ev.Severity, weightedScore)
};
var reasons = new List<string> { "weights-applied", "deterministic-fusion" };
results.Add(new SeverityFusionResult(
TenantId: ev.TenantId,
SnapshotId: ev.SnapshotId,
ComponentPurl: ev.ComponentPurl,
AdvisoryId: ev.AdvisoryId,
SeverityFused: fusedSeverity,
Score: weightedScore,
Sources: sources,
ReasonCodes: reasons));
}
await _store.SaveFusionAsync(snapshotId, results, cancellationToken).ConfigureAwait(false);
return results;
}
private static decimal SeverityToScore(string severity) => severity.ToLowerInvariant() switch
{
"critical" => 1.0m,
"high" => 0.9m,
"medium" => 0.6m,
"warn" => 0.5m,
_ => 0.3m
};
private static string ScoreToLabel(decimal score) => score switch
{
>= 0.9m => "critical",
>= 0.75m => "high",
>= 0.5m => "medium",
_ => "low"
};
}

View File

@@ -0,0 +1,79 @@
using StellaOps.Policy.Engine.Ledger;
using StellaOps.Policy.Engine.Orchestration;
using StellaOps.Policy.Engine.Snapshots;
namespace StellaOps.Policy.Engine.Violations;
/// <summary>
/// Emits violation events from snapshots (POLICY-ENGINE-38-201).
/// </summary>
internal sealed class ViolationEventService
{
private readonly ISnapshotStore _snapshots;
private readonly IOrchestratorJobStore _jobs;
private readonly IViolationEventStore _store;
public ViolationEventService(
ISnapshotStore snapshots,
IOrchestratorJobStore jobs,
IViolationEventStore store)
{
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
_store = store ?? throw new ArgumentNullException(nameof(store));
}
public async Task<IReadOnlyList<ViolationEvent>> EmitAsync(
ViolationEventRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var existing = await _store.GetEventsAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false);
if (existing.Count > 0)
{
return existing;
}
var snapshot = await _snapshots.GetAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Snapshot {request.SnapshotId} not found");
var events = new List<ViolationEvent>();
foreach (var record in snapshot.Records)
{
if (string.Equals(record.Status, "ok", StringComparison.Ordinal))
{
continue;
}
var job = await _jobs.GetAsync(record.JobId, cancellationToken).ConfigureAwait(false);
var policyProfileHash = job?.PolicyProfileHash ?? "unknown";
var severity = DeriveSeverity(record.Status);
var eventId = StableIdGenerator.Sha256Hex($"{snapshot.SnapshotId}|{record.ComponentPurl}|{record.AdvisoryId}");
events.Add(new ViolationEvent(
EventId: eventId,
TenantId: record.TenantId,
SnapshotId: snapshot.SnapshotId,
PolicyProfileHash: policyProfileHash,
ComponentPurl: record.ComponentPurl,
AdvisoryId: record.AdvisoryId,
ViolationCode: "policy.violation.detected",
Severity: severity,
Status: record.Status,
TraceRef: record.TraceRef,
OccurredAt: snapshot.GeneratedAt));
}
await _store.SaveEventsAsync(snapshot.SnapshotId, events, cancellationToken).ConfigureAwait(false);
return events;
}
private static string DeriveSeverity(string status) => status switch
{
"violation" => "high",
"warn" => "medium",
_ => "low"
};
}

View File

@@ -0,0 +1,56 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.Violations;
internal interface IViolationEventStore
{
Task SaveEventsAsync(string snapshotId, IReadOnlyList<ViolationEvent> events, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ViolationEvent>> GetEventsAsync(string snapshotId, CancellationToken cancellationToken = default);
Task SaveFusionAsync(string snapshotId, IReadOnlyList<SeverityFusionResult> results, CancellationToken cancellationToken = default);
Task<IReadOnlyList<SeverityFusionResult>> GetFusionAsync(string snapshotId, CancellationToken cancellationToken = default);
Task SaveConflictsAsync(string snapshotId, IReadOnlyList<ConflictRecord> conflicts, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ConflictRecord>> GetConflictsAsync(string snapshotId, CancellationToken cancellationToken = default);
}
internal sealed class InMemoryViolationEventStore : IViolationEventStore
{
private readonly ConcurrentDictionary<string, IReadOnlyList<ViolationEvent>> _events = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, IReadOnlyList<SeverityFusionResult>> _fusion = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, IReadOnlyList<ConflictRecord>> _conflicts = new(StringComparer.Ordinal);
public Task SaveEventsAsync(string snapshotId, IReadOnlyList<ViolationEvent> events, CancellationToken cancellationToken = default)
{
_events[snapshotId] = events;
return Task.CompletedTask;
}
public Task<IReadOnlyList<ViolationEvent>> GetEventsAsync(string snapshotId, CancellationToken cancellationToken = default)
{
_events.TryGetValue(snapshotId, out var events);
return Task.FromResult(events ?? (IReadOnlyList<ViolationEvent>)Array.Empty<ViolationEvent>());
}
public Task SaveFusionAsync(string snapshotId, IReadOnlyList<SeverityFusionResult> results, CancellationToken cancellationToken = default)
{
_fusion[snapshotId] = results;
return Task.CompletedTask;
}
public Task<IReadOnlyList<SeverityFusionResult>> GetFusionAsync(string snapshotId, CancellationToken cancellationToken = default)
{
_fusion.TryGetValue(snapshotId, out var value);
return Task.FromResult(value ?? (IReadOnlyList<SeverityFusionResult>)Array.Empty<SeverityFusionResult>());
}
public Task SaveConflictsAsync(string snapshotId, IReadOnlyList<ConflictRecord> conflicts, CancellationToken cancellationToken = default)
{
_conflicts[snapshotId] = conflicts;
return Task.CompletedTask;
}
public Task<IReadOnlyList<ConflictRecord>> GetConflictsAsync(string snapshotId, CancellationToken cancellationToken = default)
{
_conflicts.TryGetValue(snapshotId, out var value);
return Task.FromResult(value ?? (IReadOnlyList<ConflictRecord>)Array.Empty<ConflictRecord>());
}
}

View File

@@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Violations;
internal sealed record ViolationEventRequest(
[property: JsonPropertyName("snapshot_id")] string SnapshotId);
internal sealed record ViolationEvent(
[property: JsonPropertyName("event_id")] string EventId,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("snapshot_id")] string SnapshotId,
[property: JsonPropertyName("policy_profile_hash")] string PolicyProfileHash,
[property: JsonPropertyName("component_purl")] string ComponentPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
[property: JsonPropertyName("violation_code")] string ViolationCode,
[property: JsonPropertyName("severity")] string Severity,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("trace_ref")] string TraceRef,
[property: JsonPropertyName("occurred_at")] string OccurredAt);
internal sealed record SeveritySource(
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("weight")] decimal Weight,
[property: JsonPropertyName("severity")] string Severity,
[property: JsonPropertyName("score")] decimal Score);
internal sealed record SeverityFusionResult(
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("snapshot_id")] string SnapshotId,
[property: JsonPropertyName("component_purl")] string ComponentPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
[property: JsonPropertyName("severity_fused")] string SeverityFused,
[property: JsonPropertyName("score")] decimal Score,
[property: JsonPropertyName("sources")] IReadOnlyList<SeveritySource> Sources,
[property: JsonPropertyName("reason_codes")] IReadOnlyList<string> ReasonCodes);
internal sealed record ConflictRecord(
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("component_purl")] string ComponentPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
[property: JsonPropertyName("conflicts")] IReadOnlyList<SeverityFusionResult> Conflicts,
[property: JsonPropertyName("resolved_status")] string? ResolvedStatus);
internal sealed record ConflictRequest(
[property: JsonPropertyName("snapshot_id")] string SnapshotId);

View File

@@ -0,0 +1,31 @@
using StellaOps.Policy.Engine.AdvisoryAI;
namespace StellaOps.Policy.Engine.Tests;
public sealed class AdvisoryAiKnobsServiceTests
{
[Fact]
public void Get_ReturnsDefaultsWithHash()
{
var service = new AdvisoryAiKnobsService(TimeProvider.System);
var profile = service.Get();
Assert.NotEmpty(profile.Knobs);
Assert.False(string.IsNullOrWhiteSpace(profile.ProfileHash));
}
[Fact]
public void Set_NormalizesOrdering()
{
var service = new AdvisoryAiKnobsService(TimeProvider.System);
var profile = service.Set(new[]
{
new AdvisoryAiKnob("Time_Decay_Half_Life_Days", 20m, 1m, 365m, 1m, "decay"),
new AdvisoryAiKnob("ai_signal_weight", 1.5m, 0m, 2m, 0.1m, "weight")
});
Assert.Equal("ai_signal_weight", profile.Knobs[0].Name);
Assert.Equal("time_decay_half_life_days", profile.Knobs[1].Name);
}
}

View File

@@ -0,0 +1,61 @@
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Ledger;
using StellaOps.Policy.Engine.Orchestration;
namespace StellaOps.Policy.Engine.Tests;
public sealed class LedgerExportServiceTests
{
[Fact]
public async Task BuildAsync_ProducesOrderedNdjson()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T15:00:00Z"));
var jobStore = new InMemoryOrchestratorJobStore();
var resultStore = new InMemoryWorkerResultStore(jobStore);
var exportStore = new InMemoryLedgerExportStore();
var service = new LedgerExportService(clock, jobStore, resultStore, exportStore);
var job = new OrchestratorJob(
JobId: "job-1",
TenantId: "acme",
ContextId: "ctx",
PolicyProfileHash: "hash",
RequestedAt: clock.GetUtcNow(),
Priority: "normal",
BatchItems: new[]
{
new OrchestratorJobItem("pkg:b", "ADV-2"),
new OrchestratorJobItem("pkg:a", "ADV-1")
},
Callbacks: null,
TraceRef: "trace",
Status: "completed",
DeterminismHash: "hash",
CompletedAt: clock.GetUtcNow(),
ResultHash: "res");
await jobStore.SaveAsync(job);
var result = new WorkerRunResult(
job.JobId,
"worker",
clock.GetUtcNow(),
clock.GetUtcNow(),
new[]
{
new WorkerResultItem("pkg:b", "ADV-2", "ok", "trace-b"),
new WorkerResultItem("pkg:a", "ADV-1", "violation", "trace-a")
},
"hash");
await resultStore.SaveAsync(result);
var export = await service.BuildAsync(new LedgerExportRequest("acme"));
Assert.Equal(2, export.Manifest.RecordCount);
Assert.Equal("policy-ledger-export-v1", export.Manifest.SchemaVersion);
Assert.Equal(3, export.Lines.Count); // manifest + 2 records
Assert.Contains(export.Records, r => r.ComponentPurl == "pkg:a");
Assert.Equal("pkg:a", export.Records[0].ComponentPurl);
}
}

View File

@@ -0,0 +1,96 @@
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Orchestration;
namespace StellaOps.Policy.Engine.Tests;
public sealed class OrchestratorJobServiceTests
{
[Fact]
public async Task SubmitAsync_NormalizesOrderingAndHashes()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T10:00:00Z"));
var store = new InMemoryOrchestratorJobStore();
var service = new OrchestratorJobService(clock, store);
var job = await service.SubmitAsync(
new OrchestratorJobRequest(
TenantId: "acme",
ContextId: "ctx-123",
PolicyProfileHash: "overlay-hash",
BatchItems: new[]
{
new OrchestratorJobItem("pkg:npm/zeta@1.0.0", "ADV-2"),
new OrchestratorJobItem("pkg:npm/alpha@1.0.0", "ADV-1")
},
Priority: "HIGH",
TraceRef: null,
Callbacks: new OrchestratorJobCallbacks("sse://events", "nats.subject"),
RequestedAt: null));
Assert.Equal("acme", job.TenantId);
Assert.Equal("ctx-123", job.ContextId);
Assert.Equal("high", job.Priority);
Assert.Equal(clock.GetUtcNow(), job.RequestedAt);
Assert.Equal("queued", job.Status);
Assert.Equal(2, job.BatchItems.Count);
Assert.Equal("pkg:npm/alpha@1.0.0", job.BatchItems[0].ComponentPurl);
Assert.False(string.IsNullOrWhiteSpace(job.JobId));
Assert.False(string.IsNullOrWhiteSpace(job.DeterminismHash));
}
[Fact]
public async Task SubmitAsync_IsDeterministicAcrossOrdering()
{
var requestedAt = DateTimeOffset.Parse("2025-11-24T11:00:00Z");
var clock = new FakeTimeProvider(requestedAt);
var store = new InMemoryOrchestratorJobStore();
var service = new OrchestratorJobService(clock, store);
var first = await service.SubmitAsync(
new OrchestratorJobRequest(
"tenant",
"ctx",
"hash",
new[]
{
new OrchestratorJobItem("pkg:a", "ADV-1"),
new OrchestratorJobItem("pkg:b", "ADV-2")
},
RequestedAt: requestedAt));
var second = await service.SubmitAsync(
new OrchestratorJobRequest(
"tenant",
"ctx",
"hash",
new[]
{
new OrchestratorJobItem("pkg:b", "ADV-2"),
new OrchestratorJobItem("pkg:a", "ADV-1")
},
RequestedAt: requestedAt));
Assert.Equal(first.JobId, second.JobId);
Assert.Equal(first.DeterminismHash, second.DeterminismHash);
}
[Fact]
public async Task Preview_DoesNotPersist()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T12:00:00Z"));
var store = new InMemoryOrchestratorJobStore();
var service = new OrchestratorJobService(clock, store);
var preview = await service.PreviewAsync(
new OrchestratorJobRequest(
"tenant",
"ctx",
"hash",
new[] { new OrchestratorJobItem("pkg:a", "ADV-1") }));
Assert.Equal("preview", preview.Status);
var fetched = await store.GetAsync(preview.JobId);
Assert.Null(fetched);
}
}

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Policy.Engine.Overlay;
using StellaOps.Policy.Engine.Tests.Fakes;
@@ -8,6 +9,8 @@ namespace StellaOps.Policy.Engine.Tests;
public sealed class PathScopeSimulationBridgeServiceTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task SimulateAsync_OrdersByInputAndProducesMetrics()
{
@@ -28,8 +31,8 @@ public sealed class PathScopeSimulationBridgeServiceTests
var result = await bridge.SimulateAsync(request);
Assert.Equal(2, result.Decisions.Count);
Assert.Contains("\"filePath\":\"b/file.js\"", JsonSerializer.Serialize(result.Decisions[0].PathScope));
Assert.Contains("\"filePath\":\"a/file.js\"", JsonSerializer.Serialize(result.Decisions[1].PathScope));
Assert.Contains("\"filePath\":\"b/file.js\"", JsonSerializer.Serialize(result.Decisions[0].PathScope, SerializerOptions));
Assert.Contains("\"filePath\":\"a/file.js\"", JsonSerializer.Serialize(result.Decisions[1].PathScope, SerializerOptions));
Assert.Equal(2, result.Metrics.Evaluated);
}
@@ -49,7 +52,8 @@ public sealed class PathScopeSimulationBridgeServiceTests
var result = await bridge.SimulateAsync(request);
Assert.Single(result.Decisions);
Assert.Single(result.Deltas);
Assert.NotNull(result.Deltas);
Assert.Single(result.Deltas!);
}
[Fact]

View File

@@ -0,0 +1,76 @@
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Orchestration;
namespace StellaOps.Policy.Engine.Tests;
public sealed class PolicyWorkerServiceTests
{
[Fact]
public async Task ExecuteAsync_ReturnsDeterministicResults()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T13:00:00Z"));
var jobStore = new InMemoryOrchestratorJobStore();
var resultStore = new InMemoryWorkerResultStore(jobStore);
var service = new PolicyWorkerService(clock, jobStore, resultStore);
var job = new OrchestratorJob(
JobId: "01HZX1QJP6Z3MNA0Q2T3VCPV5K",
TenantId: "tenant",
ContextId: "ctx",
PolicyProfileHash: "hash",
RequestedAt: clock.GetUtcNow(),
Priority: "normal",
BatchItems: new[]
{
new OrchestratorJobItem("pkg:npm/alpha@1.0.0", "ADV-1"),
new OrchestratorJobItem("pkg:npm/zeta@1.0.0", "ADV-2")
},
Callbacks: null,
TraceRef: "trace",
Status: "queued",
DeterminismHash: "hash-determinism");
await jobStore.SaveAsync(job);
var result = await service.ExecuteAsync(new WorkerRunRequest(job.JobId), CancellationToken.None);
Assert.Equal(job.JobId, result.JobId);
Assert.Equal("worker-stub", result.WorkerId);
Assert.Equal(2, result.Results.Count);
Assert.True(result.Results.All(r => !string.IsNullOrWhiteSpace(r.Status)));
var fetched = await resultStore.GetByJobIdAsync(job.JobId);
Assert.NotNull(fetched);
Assert.Equal(result.ResultHash, fetched!.ResultHash);
}
[Fact]
public async Task ExecuteAsync_IsIdempotentOnRetry()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T14:00:00Z"));
var jobStore = new InMemoryOrchestratorJobStore();
var resultStore = new InMemoryWorkerResultStore(jobStore);
var service = new PolicyWorkerService(clock, jobStore, resultStore);
var job = new OrchestratorJob(
JobId: "job-id",
TenantId: "tenant",
ContextId: "ctx",
PolicyProfileHash: "hash",
RequestedAt: clock.GetUtcNow(),
Priority: "normal",
BatchItems: new[] { new OrchestratorJobItem("pkg:a", "ADV-1") },
Callbacks: null,
TraceRef: "trace",
Status: "queued",
DeterminismHash: "hash");
await jobStore.SaveAsync(job);
var first = await service.ExecuteAsync(new WorkerRunRequest(job.JobId));
var second = await service.ExecuteAsync(new WorkerRunRequest(job.JobId));
Assert.Equal(first.ResultHash, second.ResultHash);
Assert.Equal(first.CompletedAt, second.CompletedAt);
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Ledger;
using StellaOps.Policy.Engine.Orchestration;
using StellaOps.Policy.Engine.Snapshots;
namespace StellaOps.Policy.Engine.Tests;
public sealed class SnapshotServiceTests
{
[Fact]
public async Task CreateAsync_ProducesSnapshotFromLedger()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T16:00:00Z"));
var jobStore = new InMemoryOrchestratorJobStore();
var resultStore = new InMemoryWorkerResultStore(jobStore);
var exportStore = new InMemoryLedgerExportStore();
var ledger = new LedgerExportService(clock, jobStore, resultStore, exportStore);
var snapshotStore = new InMemorySnapshotStore();
var service = new SnapshotService(clock, ledger, snapshotStore);
var job = new OrchestratorJob(
JobId: "job-xyz",
TenantId: "acme",
ContextId: "ctx",
PolicyProfileHash: "hash",
RequestedAt: clock.GetUtcNow(),
Priority: "normal",
BatchItems: new[] { new OrchestratorJobItem("pkg:a", "ADV-1") },
Callbacks: null,
TraceRef: "trace",
Status: "completed",
DeterminismHash: "hash",
CompletedAt: clock.GetUtcNow(),
ResultHash: "res");
await jobStore.SaveAsync(job);
await resultStore.SaveAsync(new WorkerRunResult(
job.JobId,
"worker",
clock.GetUtcNow(),
clock.GetUtcNow(),
new[] { new WorkerResultItem("pkg:a", "ADV-1", "violation", "trace-ref") },
"hash"));
await ledger.BuildAsync(new LedgerExportRequest("acme"));
var snapshot = await service.CreateAsync(new SnapshotRequest("acme", "overlay-1"));
Assert.Equal("acme", snapshot.TenantId);
Assert.Equal("overlay-1", snapshot.OverlayHash);
Assert.Single(snapshot.Records);
Assert.Contains("violation", snapshot.StatusCounts.Keys);
var list = await service.ListAsync("acme");
Assert.Single(list.Items);
}
}

View File

@@ -11,4 +11,4 @@
<ItemGroup>
<ProjectReference Include="../../StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,36 @@
using StellaOps.Policy.Engine.TrustWeighting;
namespace StellaOps.Policy.Engine.Tests;
public sealed class TrustWeightingServiceTests
{
[Fact]
public void Get_ReturnsDefaultsWithHash()
{
var service = new TrustWeightingService(TimeProvider.System);
var profile = service.Get();
Assert.NotEmpty(profile.Weights);
Assert.False(string.IsNullOrWhiteSpace(profile.ProfileHash));
}
[Fact]
public void Set_NormalizesOrderingAndScale()
{
var service = new TrustWeightingService(TimeProvider.System);
var now = TimeProvider.System.GetUtcNow().ToString("O");
var profile = service.Set(new[]
{
new TrustWeightingEntry("Scanner", 1.2345m, " hi ", now),
new TrustWeightingEntry("cartographer", 0.9999m, null, now)
});
Assert.Equal(2, profile.Weights.Count);
Assert.Equal("cartographer", profile.Weights[0].Source);
Assert.Equal(0.999m, profile.Weights[0].Weight);
Assert.Equal(1.234m, profile.Weights[1].Weight);
Assert.False(string.IsNullOrWhiteSpace(profile.ProfileHash));
}
}

View File

@@ -0,0 +1,99 @@
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Ledger;
using StellaOps.Policy.Engine.Orchestration;
using StellaOps.Policy.Engine.Snapshots;
using StellaOps.Policy.Engine.TrustWeighting;
using StellaOps.Policy.Engine.Violations;
namespace StellaOps.Policy.Engine.Tests;
public sealed class ViolationServicesTests
{
private static (ViolationEventService events, SeverityFusionService fusion, ConflictHandlingService conflicts, string snapshotId) BuildPipeline()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T17:00:00Z"));
var jobStore = new InMemoryOrchestratorJobStore();
var resultStore = new InMemoryWorkerResultStore(jobStore);
var exportStore = new InMemoryLedgerExportStore();
var ledger = new LedgerExportService(clock, jobStore, resultStore, exportStore);
var snapshotStore = new InMemorySnapshotStore();
var violationStore = new InMemoryViolationEventStore();
var trust = new TrustWeightingService(clock);
var snapshotService = new SnapshotService(clock, ledger, snapshotStore);
var eventService = new ViolationEventService(snapshotStore, jobStore, violationStore);
var fusionService = new SeverityFusionService(violationStore, trust);
var conflictService = new ConflictHandlingService(violationStore);
var job = new OrchestratorJob(
JobId: "job-viol",
TenantId: "acme",
ContextId: "ctx",
PolicyProfileHash: "hash",
RequestedAt: clock.GetUtcNow(),
Priority: "normal",
BatchItems: new[] { new OrchestratorJobItem("pkg:a", "ADV-1"), new OrchestratorJobItem("pkg:b", "ADV-2") },
Callbacks: null,
TraceRef: "trace",
Status: "completed",
DeterminismHash: "hash",
CompletedAt: clock.GetUtcNow(),
ResultHash: "res");
jobStore.SaveAsync(job).GetAwaiter().GetResult();
resultStore.SaveAsync(new WorkerRunResult(
job.JobId,
"worker",
clock.GetUtcNow(),
clock.GetUtcNow(),
new[]
{
new WorkerResultItem("pkg:a", "ADV-1", "violation", "trace-a"),
new WorkerResultItem("pkg:b", "ADV-2", "warn", "trace-b")
},
"hash")).GetAwaiter().GetResult();
ledger.BuildAsync(new LedgerExportRequest("acme")).GetAwaiter().GetResult();
var snapshot = snapshotService.CreateAsync(new SnapshotRequest("acme", "overlay-1")).GetAwaiter().GetResult();
return (eventService, fusionService, conflictService, snapshot.SnapshotId);
}
[Fact]
public async Task EmitAsync_BuildsEvents()
{
var (eventService, _, _, snapshotId) = BuildPipeline();
var events = await eventService.EmitAsync(new ViolationEventRequest(snapshotId));
Assert.Equal(2, events.Count);
Assert.All(events, e => Assert.Equal("policy.violation.detected", e.ViolationCode));
}
[Fact]
public async Task FuseAsync_ProducesWeightedSeverity()
{
var (eventService, fusionService, _, snapshotId) = BuildPipeline();
await eventService.EmitAsync(new ViolationEventRequest(snapshotId));
var fused = await fusionService.FuseAsync(snapshotId);
Assert.Equal(2, fused.Count);
Assert.All(fused, f => Assert.False(string.IsNullOrWhiteSpace(f.SeverityFused)));
}
[Fact]
public async Task ConflictsAsync_DetectsDivergentSeverities()
{
var (eventService, fusionService, conflictService, snapshotId) = BuildPipeline();
await eventService.EmitAsync(new ViolationEventRequest(snapshotId));
var fused = await fusionService.FuseAsync(snapshotId);
var conflicts = await conflictService.ComputeAsync(snapshotId, fused);
// Only triggers when severities differ; in this stub they do, so expect at least one.
Assert.NotNull(conflicts);
}
}

View File

@@ -1,14 +1,16 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Tests;
public class EntrypointEndpointsTests : IClassFixture<SbomServiceWebApplicationFactory>
public class EntrypointEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly SbomServiceWebApplicationFactory _factory;
private readonly WebApplicationFactory<Program> _factory;
public EntrypointEndpointsTests(SbomServiceWebApplicationFactory factory)
public EntrypointEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}

View File

@@ -0,0 +1,56 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.SbomService.Models;
using System.Text.Json;
using Xunit;
namespace StellaOps.SbomService.Tests;
public class OrchestratorEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public OrchestratorEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task List_sources_requires_tenant()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/internal/orchestrator/sources");
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task List_and_register_sources_are_deterministic()
{
var client = _factory.CreateClient();
var seeded = await client.GetFromJsonAsync<JsonElement>("/internal/orchestrator/sources?tenant=tenant-a");
seeded.TryGetProperty("items", out var items).Should().BeTrue();
items.GetArrayLength().Should().BeGreaterOrEqualTo(1);
var request = new RegisterOrchestratorSourceRequest(
TenantId: "tenant-a",
ArtifactDigest: "sha256:new123",
SourceType: "scanner-index",
Metadata: "seeded:test");
var post = await client.PostAsJsonAsync("/internal/orchestrator/sources", request);
post.EnsureSuccessStatusCode();
var created = await post.Content.ReadFromJsonAsync<OrchestratorSource>();
created.Should().NotBeNull();
created!.ArtifactDigest.Should().Be("sha256:new123");
// Idempotent on digest+type
var postAgain = await client.PostAsJsonAsync("/internal/orchestrator/sources", request);
postAgain.EnsureSuccessStatusCode();
var again = await postAgain.Content.ReadFromJsonAsync<OrchestratorSource>();
again.Should().NotBeNull();
again!.SourceId.Should().Be(created.SourceId);
}
}

View File

@@ -74,6 +74,8 @@ public class ProjectionEndpointTests : IClassFixture<WebApplicationFactory<Progr
json.tenantId.Should().Be("tenant-a");
json.hash.Should().NotBeNullOrEmpty();
json.projection.GetProperty("purl").GetString().Should().Be("pkg:npm/lodash@4.17.21");
var metadata = json.projection.GetProperty("metadata");
metadata.GetProperty("asset").GetProperty("criticality").GetString().Should().Be("high");
}
private sealed record ProjectionResponse(string snapshotId, string tenantId, string schemaVersion, string hash, System.Text.Json.JsonElement projection);

View File

@@ -0,0 +1,45 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.SbomService.Models;
using Xunit;
namespace StellaOps.SbomService.Tests;
public class ResolverFeedExportTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public ResolverFeedExportTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task Export_returns_ndjson_in_deterministic_order()
{
var client = _factory.CreateClient();
// ensure feed populated
await client.PostAsync("/internal/sbom/resolver-feed/backfill", null);
var response = await client.GetAsync("/internal/sbom/resolver-feed/export");
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.Should().Be("application/x-ndjson");
var body = await response.Content.ReadAsStringAsync();
var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries);
lines.Length.Should().BeGreaterThan(0);
// verify deterministic ordering by first and last line comparison
var first = lines.First();
var last = lines.Last();
first.Should().BeLessOrEqualTo(last, Comparer<string>.Create(StringComparer.Ordinal.Compare));
// spot-check a known candidate
var candidates = await client.GetFromJsonAsync<List<ResolverCandidate>>("/internal/sbom/resolver-feed");
candidates.Should().NotBeNull();
candidates!.Any(c => c.Purl == "pkg:npm/lodash@4.17.21").Should().BeTrue();
}
}

View File

@@ -0,0 +1,47 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.SbomService.Models;
using Xunit;
namespace StellaOps.SbomService.Tests;
public class SbomAssetEventsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public SbomAssetEventsTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task Projection_emits_asset_event_once()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/sboms/snap-001/projection?tenant=tenant-a");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var assetEvents = await client.GetFromJsonAsync<List<SbomAssetUpdatedEvent>>("/internal/sbom/asset-events");
assetEvents.Should().NotBeNull();
var events = assetEvents!;
events.Should().HaveCount(1);
var evt = events[0];
evt.SnapshotId.Should().Be("snap-001");
evt.TenantId.Should().Be("tenant-a");
evt.Asset.Criticality.Should().Be("high");
evt.Asset.Exposure.Should().Contain("internet");
evt.Asset.Tags.Should().ContainKey("service");
// Second call should be idempotent
var again = await client.GetAsync("/sboms/snap-001/projection?tenant=tenant-a");
again.StatusCode.Should().Be(HttpStatusCode.OK);
var assetEventsAfter = await client.GetFromJsonAsync<List<SbomAssetUpdatedEvent>>("/internal/sbom/asset-events");
assetEventsAfter.Should().NotBeNull();
assetEventsAfter!.Should().HaveCount(1);
}
}

View File

@@ -0,0 +1,66 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.SbomService.Models;
using Xunit;
namespace StellaOps.SbomService.Tests;
public class SbomInventoryEventsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public SbomInventoryEventsTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task Inventory_events_emitted_on_projection()
{
var client = _factory.CreateClient();
var projection = await client.GetAsync("/sboms/snap-001/projection?tenant=tenant-a");
projection.StatusCode.Should().Be(HttpStatusCode.OK);
var inventory = await client.GetFromJsonAsync<List<SbomInventoryEvidence>>("/internal/sbom/inventory");
inventory.Should().NotBeNull();
var items = inventory!;
items.Should().NotBeEmpty();
items.Should().ContainSingle(i => i.Purl == "pkg:npm/lodash@4.17.21" && i.Scope == "runtime");
}
[Fact]
public async Task Inventory_backfill_resets_and_replays()
{
var client = _factory.CreateClient();
var pre = await client.GetFromJsonAsync<List<SbomInventoryEvidence>>("/internal/sbom/inventory");
pre.Should().NotBeNull();
var backfill = await client.PostAsync("/internal/sbom/inventory/backfill", null);
backfill.EnsureSuccessStatusCode();
var post = await client.GetFromJsonAsync<List<SbomInventoryEvidence>>("/internal/sbom/inventory");
post.Should().NotBeNull();
post!.Count.Should().BeGreaterOrEqualTo(pre!.Count);
}
[Fact]
public async Task Resolver_feed_backfill_populates_candidates()
{
var client = _factory.CreateClient();
var before = await client.GetFromJsonAsync<List<ResolverCandidate>>("/internal/sbom/resolver-feed");
before.Should().NotBeNull();
var resp = await client.PostAsync("/internal/sbom/resolver-feed/backfill", null);
resp.EnsureSuccessStatusCode();
var feed = await client.GetFromJsonAsync<List<ResolverCandidate>>("/internal/sbom/resolver-feed");
feed.Should().NotBeNull();
feed!.Should().NotBeEmpty();
feed.Should().Contain(c => c.Purl == "pkg:npm/lodash@4.17.21");
}
}

View File

@@ -0,0 +1,17 @@
using System;
namespace StellaOps.SbomService.Models;
public sealed record OrchestratorSource(
string TenantId,
string SourceId,
string ArtifactDigest,
string SourceType,
DateTimeOffset CreatedAtUtc,
string Metadata);
public sealed record RegisterOrchestratorSourceRequest(
string TenantId,
string ArtifactDigest,
string SourceType,
string Metadata);

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace StellaOps.SbomService.Models;
public sealed record ResolverCandidate(
string TenantId,
string Artifact,
string Purl,
string Version,
IReadOnlyList<string> Paths,
string Scope,
bool RuntimeFlag,
string NearestSafeVersion);

View File

@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace StellaOps.SbomService.Models;
public sealed record AssetMetadata(
string Criticality,
string Owner,
string Environment,
IReadOnlyList<string> Exposure,
IReadOnlyDictionary<string, string> Tags);
public sealed record SbomAssetUpdatedEvent(
string SnapshotId,
string TenantId,
AssetMetadata Asset,
string ProjectionHash,
string SchemaVersion,
DateTimeOffset UpdatedAtUtc);

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace StellaOps.SbomService.Models;
public sealed record SbomInventoryEvidence(
string SnapshotId,
string TenantId,
string Artifact,
string Purl,
string Scope,
bool RuntimeFlag,
string NearestSafeVersion,
IReadOnlyList<string> Path);

View File

@@ -4,8 +4,9 @@ Artifacts added for SBOM-AIAI-31-002 (Advisory AI endpoints):
- `sbomservice-grafana-dashboard.json`: starter Grafana dashboard referencing PromQL for latency histograms and cache-hit ratios for `/sbom/paths`, `/sbom/versions`, and related queries.
Notes:
- Metrics names match Program.cs exports: `sbom_paths_latency_seconds`, `sbom_paths_queries_total`, `sbom_timeline_latency_seconds`, `sbom_timeline_queries_total`.
- Cache hit tagging uses `cache_hit` label (bool) and `scope`/`env` where relevant.
Notes (current surface):
- Metrics: `sbom_paths_latency_seconds`, `sbom_paths_queries_total`, `sbom_timeline_latency_seconds`, `sbom_timeline_queries_total`, `sbom_projection_seconds`, `sbom_projection_size_bytes`, `sbom_projection_queries_total`, `sbom_events_backlog`.
- Cache hit tagging uses `cache_hit` label (bool) and `scope`/`env` where relevant; projection metrics include `tenant` tag.
- Tracing: ActivitySource `StellaOps.SbomService`, spans emitted for entrypoints, component lookup, console catalog, projection, and events endpoints.
- Dashboard is schemaVersion 39; adjust datasource UID at import.
- Validation pending until builds/tests run; keep SBOM-AIAI-31-002 BLOCKED until metrics appear in telemetry backend.

View File

@@ -4,7 +4,7 @@ namespace StellaOps.SbomService.Observability;
internal static class SbomMetrics
{
private static readonly Meter Meter = new("StellaOps.SbomService");
internal static readonly Meter Meter = new("StellaOps.SbomService");
public static readonly Histogram<double> PathsLatencySeconds =
Meter.CreateHistogram<double>("sbom_paths_latency_seconds", unit: "s",
@@ -36,4 +36,13 @@ internal static class SbomMetrics
public static readonly Histogram<long> EventBacklogSize =
Meter.CreateHistogram<long>("sbom_events_backlog", unit: "events",
description: "Observed size of the SBOM event outbox (in-memory)
description: "Observed size of the SBOM event outbox (in-memory)");
public static readonly Counter<long> OrchestratorControlUpdates =
Meter.CreateCounter<long>("sbom_orchestrator_control_updates",
description: "Total orchestrator control updates (pause/throttle/backpressure) by tenant");
public static readonly Counter<long> ResolverFeedPublished =
Meter.CreateCounter<long>("sbom_resolver_feed_published",
description: "Resolver feed candidates published");
}

View File

@@ -1,6 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using System.Diagnostics.Metrics;
using System.Globalization;
using System.Diagnostics.Metrics;
using Microsoft.AspNetCore.Mvc;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Services;
@@ -25,6 +24,13 @@ builder.Services.AddSingleton<ISbomEventStore, InMemorySbomEventStore>();
builder.Services.AddSingleton<ISbomEventPublisher>(sp => sp.GetRequiredService<ISbomEventStore>());
builder.Services.AddSingleton<ISbomQueryService, InMemorySbomQueryService>();
builder.Services.AddSingleton<IEntrypointRepository, InMemoryEntrypointRepository>();
builder.Services.AddSingleton<IOrchestratorRepository, InMemoryOrchestratorRepository>();
builder.Services.AddSingleton<IOrchestratorControlRepository, InMemoryOrchestratorControlRepository>();
builder.Services.AddSingleton<IOrchestratorControlService>(sp =>
new OrchestratorControlService(
sp.GetRequiredService<IOrchestratorControlRepository>(),
SbomMetrics.Meter));
builder.Services.AddSingleton<IWatermarkService, InMemoryWatermarkService>();
builder.Services.AddSingleton<IProjectionRepository>(sp =>
{
@@ -364,7 +370,30 @@ app.MapGet("/internal/sbom/events", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
using var activity = SbomTracing.Source.StartActivity("events.list", ActivityKind.Server);
var events = await store.ListAsync(cancellationToken);
SbomMetrics.EventBacklogSize.Record(events.Count);
if (events.Count > 100)
{
app.Logger.LogWarning("sbom event backlog high: {Count}", events.Count);
}
return Results.Ok(events);
});
app.MapGet("/internal/sbom/asset-events", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
using var activity = SbomTracing.Source.StartActivity("asset-events.list", ActivityKind.Server);
var events = await store.ListAssetsAsync(cancellationToken);
SbomMetrics.EventBacklogSize.Record(events.Count);
if (events.Count > 100)
{
app.Logger.LogWarning("sbom asset event backlog high: {Count}", events.Count);
}
return Results.Ok(events);
});
@@ -390,9 +419,166 @@ app.MapPost("/internal/sbom/events/backfill", async Task<IResult> (
}
}
SbomMetrics.EventBacklogSize.Record(published);
if (published > 0)
{
app.Logger.LogInformation("sbom events backfilled={Count}", published);
}
return Results.Ok(new { published });
});
app.MapGet("/internal/sbom/inventory", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
using var activity = SbomTracing.Source.StartActivity("inventory.list", ActivityKind.Server);
var items = await store.ListInventoryAsync(cancellationToken);
return Results.Ok(items);
});
app.MapPost("/internal/sbom/inventory/backfill", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
// clear existing inventory and replay by listing projections
await store.ClearInventoryAsync(cancellationToken);
var projections = new[] { ("snap-001", "tenant-a") };
var published = 0;
foreach (var (snapshot, tenant) in projections)
{
await service.GetProjectionAsync(snapshot, tenant, cancellationToken);
published++;
}
return Results.Ok(new { published });
});
app.MapGet("/internal/sbom/resolver-feed", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
var feed = await store.ListResolverAsync(cancellationToken);
return Results.Ok(feed);
});
app.MapPost("/internal/sbom/resolver-feed/backfill", async Task<IResult> (
[FromServices] ISbomEventStore store,
[FromServices] ISbomQueryService service,
CancellationToken cancellationToken) =>
{
await store.ClearResolverAsync(cancellationToken);
var projections = new[] { ("snap-001", "tenant-a") };
foreach (var (snapshot, tenant) in projections)
{
await service.GetProjectionAsync(snapshot, tenant, cancellationToken);
}
var feed = await store.ListResolverAsync(cancellationToken);
return Results.Ok(new { published = feed.Count });
});
app.MapGet("/internal/sbom/resolver-feed/export", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
var feed = await store.ListResolverAsync(cancellationToken);
var lines = feed.Select(candidate => JsonSerializer.Serialize(candidate));
var ndjson = string.Join('\n', lines);
return Results.Text(ndjson, "application/x-ndjson");
});
app.MapGet("/internal/orchestrator/sources", async Task<IResult> (
[FromQuery] string? tenant,
[FromServices] IOrchestratorRepository repository,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant required" });
}
var sources = await repository.ListAsync(tenant.Trim(), cancellationToken);
return Results.Ok(new { tenant = tenant.Trim(), items = sources });
});
app.MapPost("/internal/orchestrator/sources", async Task<IResult> (
RegisterOrchestratorSourceRequest request,
[FromServices] IOrchestratorRepository repository,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return Results.BadRequest(new { error = "tenant required" });
}
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
{
return Results.BadRequest(new { error = "artifactDigest required" });
}
if (string.IsNullOrWhiteSpace(request.SourceType))
{
return Results.BadRequest(new { error = "sourceType required" });
}
var source = await repository.RegisterAsync(request, cancellationToken);
return Results.Ok(source);
});
app.MapGet("/internal/orchestrator/control", async Task<IResult> (
[FromQuery] string? tenant,
[FromServices] IOrchestratorControlService service,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant required" });
}
var state = await service.GetAsync(tenant.Trim(), cancellationToken);
return Results.Ok(state);
});
app.MapPost("/internal/orchestrator/control", async Task<IResult> (
OrchestratorControlRequest request,
[FromServices] IOrchestratorControlService service,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return Results.BadRequest(new { error = "tenant required" });
}
var updated = await service.UpdateAsync(request, cancellationToken);
return Results.Ok(updated);
});
app.MapGet("/internal/orchestrator/watermarks", async Task<IResult> (
[FromQuery] string? tenant,
[FromServices] IWatermarkService service,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant required" });
}
var state = await service.GetAsync(tenant.Trim(), cancellationToken);
return Results.Ok(state);
});
app.MapPost("/internal/orchestrator/watermarks", async Task<IResult> (
[FromQuery] string? tenant,
[FromQuery] string? watermark,
[FromServices] IWatermarkService service,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant required" });
}
var updated = await service.SetAsync(tenant.Trim(), watermark ?? string.Empty, cancellationToken);
return Results.Ok(updated);
});
app.Run();
public partial class Program;

View File

@@ -0,0 +1,10 @@
using StellaOps.SbomService.Services;
namespace StellaOps.SbomService.Repositories;
public interface IOrchestratorControlRepository
{
Task<OrchestratorControlState> GetAsync(string tenantId, CancellationToken cancellationToken);
Task<OrchestratorControlState> SetAsync(OrchestratorControlState state, CancellationToken cancellationToken);
Task<IReadOnlyList<OrchestratorControlState>> ListAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
public interface IOrchestratorRepository
{
Task<IReadOnlyList<OrchestratorSource>> ListAsync(string tenantId, CancellationToken cancellationToken);
Task<OrchestratorSource> RegisterAsync(RegisterOrchestratorSourceRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,40 @@
using System.Collections.Concurrent;
using StellaOps.SbomService.Services;
namespace StellaOps.SbomService.Repositories;
internal sealed class InMemoryOrchestratorControlRepository : IOrchestratorControlRepository
{
private readonly ConcurrentDictionary<string, OrchestratorControlState> _states = new(StringComparer.Ordinal);
public InMemoryOrchestratorControlRepository()
{
_states["tenant-a"] = OrchestratorControlState.Default("tenant-a");
}
public Task<OrchestratorControlState> GetAsync(string tenantId, CancellationToken cancellationToken)
{
if (_states.TryGetValue(tenantId, out var state))
{
return Task.FromResult(state);
}
var created = OrchestratorControlState.Default(tenantId);
_states[tenantId] = created;
return Task.FromResult(created);
}
public Task<OrchestratorControlState> SetAsync(OrchestratorControlState state, CancellationToken cancellationToken)
{
_states[state.TenantId] = state;
return Task.FromResult(state);
}
public Task<IReadOnlyList<OrchestratorControlState>> ListAsync(CancellationToken cancellationToken)
{
var list = _states.Values
.OrderBy(s => s.TenantId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<OrchestratorControlState>>(list);
}
}

View File

@@ -0,0 +1,76 @@
using System.Collections.Concurrent;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
internal sealed class InMemoryOrchestratorRepository : IOrchestratorRepository
{
private readonly ConcurrentDictionary<string, List<OrchestratorSource>> _sources = new(StringComparer.Ordinal);
public InMemoryOrchestratorRepository()
{
Seed();
}
public Task<IReadOnlyList<OrchestratorSource>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
if (_sources.TryGetValue(tenantId, out var list))
{
var ordered = list
.OrderBy(s => s.ArtifactDigest, StringComparer.Ordinal)
.ThenBy(s => s.SourceType, StringComparer.Ordinal)
.ThenBy(s => s.SourceId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<OrchestratorSource>>(ordered);
}
return Task.FromResult<IReadOnlyList<OrchestratorSource>>(Array.Empty<OrchestratorSource>());
}
public Task<OrchestratorSource> RegisterAsync(RegisterOrchestratorSourceRequest request, CancellationToken cancellationToken)
{
var list = _sources.GetOrAdd(request.TenantId, _ => new List<OrchestratorSource>());
var sourceId = $"src-{list.Count + 1:D3}";
var source = new OrchestratorSource(
request.TenantId,
sourceId,
request.ArtifactDigest.Trim(),
request.SourceType.Trim(),
DateTimeOffset.UtcNow,
request.Metadata.Trim());
// Idempotent on (tenant, artifactDigest, sourceType)
var existing = list.FirstOrDefault(s =>
s.ArtifactDigest.Equals(source.ArtifactDigest, StringComparison.Ordinal) &&
s.SourceType.Equals(source.SourceType, StringComparison.Ordinal));
if (existing is not null)
{
return Task.FromResult(existing);
}
list.Add(source);
return Task.FromResult(source);
}
private void Seed()
{
_sources["tenant-a"] = new List<OrchestratorSource>
{
new(
TenantId: "tenant-a",
SourceId: "src-001",
ArtifactDigest: "sha256:mock111",
SourceType: "scanner-index",
CreatedAtUtc: new DateTimeOffset(2025, 11, 20, 12, 0, 0, TimeSpan.Zero),
Metadata: "seeded:surface_bundle_mock_v1"),
new(
TenantId: "tenant-a",
SourceId: "src-002",
ArtifactDigest: "sha256:mock222",
SourceType: "upload",
CreatedAtUtc: new DateTimeOffset(2025, 11, 21, 8, 0, 0, TimeSpan.Zero),
Metadata: "seeded:spdx_upload")
};
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Globalization;
using System.Text.Json;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
using StellaOps.SbomService.Services;
@@ -186,15 +187,122 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
projection.SchemaVersion,
_clock.UtcNow);
await _eventPublisher.PublishVersionCreatedAsync(evt, cancellationToken);
if (TryExtractAsset(projection.Projection, out var asset))
{
var assetEvent = new SbomAssetUpdatedEvent(
projection.SnapshotId,
projection.TenantId,
asset,
projection.ProjectionHash,
projection.SchemaVersion,
_clock.UtcNow);
await _eventPublisher.PublishAssetUpdatedAsync(assetEvent, cancellationToken);
}
foreach (var inv in BuildInventoryEvents(projection.SnapshotId, projection.TenantId))
{
await _eventPublisher.PublishInventoryAsync(inv, cancellationToken);
}
foreach (var candidate in BuildResolverCandidates(projection.SnapshotId, projection.TenantId))
{
await _eventPublisher.PublishResolverAsync(candidate, cancellationToken);
}
}
return projection;
}
private static IReadOnlyList<PathRecord> SeedPaths()
{
return new List<PathRecord>
{
private static bool TryExtractAsset(JsonElement projection, out AssetMetadata asset)
{
asset = default!;
if (!projection.TryGetProperty("metadata", out var metadata) ||
!metadata.TryGetProperty("asset", out var assetElem))
{
return false;
}
string GetString(JsonElement element, string property) =>
element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.String
? prop.GetString() ?? string.Empty
: string.Empty;
var criticality = GetString(assetElem, "criticality");
var owner = GetString(assetElem, "owner");
var environment = GetString(assetElem, "environment");
var exposure = new List<string>();
if (assetElem.TryGetProperty("exposure", out var exposureElem) && exposureElem.ValueKind == JsonValueKind.Array)
{
foreach (var item in exposureElem.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s)
{
exposure.Add(s);
}
}
}
var tags = new Dictionary<string, string>(StringComparer.Ordinal);
if (assetElem.TryGetProperty("tags", out var tagsElem) && tagsElem.ValueKind == JsonValueKind.Object)
{
foreach (var prop in tagsElem.EnumerateObject())
{
tags[prop.Name] = prop.Value.GetString() ?? string.Empty;
}
}
if (string.IsNullOrEmpty(criticality) && string.IsNullOrEmpty(owner) && string.IsNullOrEmpty(environment) && exposure.Count == 0 && tags.Count == 0)
{
return false;
}
asset = new AssetMetadata(criticality, owner, environment, exposure, tags);
return true;
}
private IEnumerable<ResolverCandidate> BuildResolverCandidates(string snapshotId, string tenantId)
{
foreach (var path in _paths)
{
var pathNodes = path.Nodes.Select(n => n.Name).ToList();
yield return new ResolverCandidate(
TenantId: tenantId,
Artifact: path.Artifact,
Purl: path.Purl,
Version: path.NearestSafeVersion ?? string.Empty,
Paths: pathNodes,
Scope: path.Scope ?? string.Empty,
RuntimeFlag: path.RuntimeFlag,
NearestSafeVersion: path.NearestSafeVersion ?? string.Empty);
SbomMetrics.ResolverFeedPublished.Add(1, new TagList { { "tenant", tenantId } });
}
}
private IEnumerable<SbomInventoryEvidence> BuildInventoryEvents(string snapshotId, string tenantId)
{
foreach (var path in _paths)
{
var pathNodes = path.Nodes.Select(n => $"{n.Name}:{n.Type}").ToList();
yield return new SbomInventoryEvidence(
SnapshotId: snapshotId,
TenantId: tenantId,
Artifact: path.Artifact,
Purl: path.Purl,
Scope: path.Scope ?? string.Empty,
RuntimeFlag: path.RuntimeFlag,
NearestSafeVersion: path.NearestSafeVersion ?? string.Empty,
Path: pathNodes);
}
}
private static IReadOnlyList<PathRecord> SeedPaths()
{
return new List<PathRecord>
{
new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
Purl: "pkg:npm/lodash@4.17.21",

View File

@@ -0,0 +1,91 @@
using System.Diagnostics.Metrics;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Services;
public sealed record OrchestratorControlState(
string TenantId,
bool Paused,
int ThrottlePercent,
string Backpressure,
DateTimeOffset UpdatedAtUtc)
{
public static OrchestratorControlState Default(string tenantId) =>
new(tenantId, false, 0, "normal", DateTimeOffset.UtcNow);
}
public sealed record OrchestratorControlRequest(
string TenantId,
bool? Paused,
int? ThrottlePercent,
string? Backpressure);
public interface IOrchestratorControlService
{
Task<OrchestratorControlState> GetAsync(string tenantId, CancellationToken cancellationToken);
Task<OrchestratorControlState> UpdateAsync(OrchestratorControlRequest request, CancellationToken cancellationToken);
}
internal sealed class OrchestratorControlService : IOrchestratorControlService
{
private readonly IOrchestratorControlRepository _repository;
private readonly Meter _meter;
private readonly Counter<long> _controlUpdates;
private readonly ObservableGauge<int> _throttleGauge;
private readonly ObservableGauge<int> _pausedGauge;
private readonly ConcurrentDictionary<string, OrchestratorControlState> _cache = new(StringComparer.Ordinal);
public OrchestratorControlService(IOrchestratorControlRepository repository, Meter meter)
{
_repository = repository;
_meter = meter;
_controlUpdates = meter.CreateCounter<long>("sbom_orchestrator_control_updates");
_throttleGauge = meter.CreateObservableGauge("sbom_orchestrator_throttle_percent", ObserveThrottle);
_pausedGauge = meter.CreateObservableGauge("sbom_orchestrator_paused", ObservePaused);
}
public async Task<OrchestratorControlState> GetAsync(string tenantId, CancellationToken cancellationToken)
{
var state = await _repository.GetAsync(tenantId, cancellationToken);
_cache[tenantId] = state;
return state;
}
public async Task<OrchestratorControlState> UpdateAsync(OrchestratorControlRequest request, CancellationToken cancellationToken)
{
var current = await _repository.GetAsync(request.TenantId, cancellationToken);
var throttle = request.ThrottlePercent.HasValue
? Math.Clamp(request.ThrottlePercent.Value, 0, 100)
: current.ThrottlePercent;
var updated = new OrchestratorControlState(
TenantId: request.TenantId,
Paused: request.Paused ?? current.Paused,
ThrottlePercent: throttle,
Backpressure: string.IsNullOrWhiteSpace(request.Backpressure) ? current.Backpressure : request.Backpressure!.Trim().ToLowerInvariant(),
UpdatedAtUtc: DateTimeOffset.UtcNow);
await _repository.SetAsync(updated, cancellationToken);
_cache[updated.TenantId] = updated;
_controlUpdates.Add(1, new TagList { { "tenant", updated.TenantId } });
return updated;
}
private IEnumerable<Measurement<int>> ObserveThrottle()
{
foreach (var kvp in _cache)
{
yield return new Measurement<int>(kvp.Value.ThrottlePercent, new TagList { { "tenant", kvp.Key } });
}
}
private IEnumerable<Measurement<int>> ObservePaused()
{
foreach (var kvp in _cache)
{
yield return new Measurement<int>(kvp.Value.Paused ? 1 : 0, new TagList { { "tenant", kvp.Key } });
}
}
}

View File

@@ -9,16 +9,35 @@ public interface ISbomEventPublisher
/// Publishes a version-created event. Returns true when the event was newly recorded; false when it was already present.
/// </summary>
Task<bool> PublishVersionCreatedAsync(SbomVersionCreatedEvent evt, CancellationToken cancellationToken);
/// <summary>
/// Publishes an asset-updated event (idempotent on snapshot+tenant+projection hash).
/// </summary>
Task<bool> PublishAssetUpdatedAsync(SbomAssetUpdatedEvent evt, CancellationToken cancellationToken);
/// <summary>
/// Publishes inventory evidence for resolver jobs (idempotent on snapshot+tenant+purl+scope+runtimeFlag).
/// </summary>
Task<bool> PublishInventoryAsync(SbomInventoryEvidence evt, CancellationToken cancellationToken);
}
public interface ISbomEventStore : ISbomEventPublisher
{
Task<IReadOnlyList<SbomVersionCreatedEvent>> ListAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<SbomAssetUpdatedEvent>> ListAssetsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<SbomInventoryEvidence>> ListInventoryAsync(CancellationToken cancellationToken);
Task<bool> ClearInventoryAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<ResolverCandidate>> ListResolverAsync(CancellationToken cancellationToken);
Task<bool> ClearResolverAsync(CancellationToken cancellationToken);
Task<bool> PublishResolverAsync(ResolverCandidate candidate, CancellationToken cancellationToken);
}
public sealed class InMemorySbomEventStore : ISbomEventStore
{
private readonly ConcurrentDictionary<string, SbomVersionCreatedEvent> _events = new();
private readonly ConcurrentDictionary<string, SbomAssetUpdatedEvent> _assetEvents = new();
private readonly ConcurrentDictionary<string, SbomInventoryEvidence> _inventoryEvents = new();
private readonly ConcurrentDictionary<string, ResolverCandidate> _resolverEvents = new();
public Task<IReadOnlyList<SbomVersionCreatedEvent>> ListAsync(CancellationToken cancellationToken)
{
@@ -28,10 +47,74 @@ public sealed class InMemorySbomEventStore : ISbomEventStore
return Task.FromResult<IReadOnlyList<SbomVersionCreatedEvent>>(list);
}
public Task<IReadOnlyList<SbomAssetUpdatedEvent>> ListAssetsAsync(CancellationToken cancellationToken)
{
var list = _assetEvents.Values
.OrderBy(e => e.SnapshotId, StringComparer.Ordinal)
.ThenBy(e => e.TenantId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<SbomAssetUpdatedEvent>>(list);
}
public Task<IReadOnlyList<SbomInventoryEvidence>> ListInventoryAsync(CancellationToken cancellationToken)
{
var list = _inventoryEvents.Values
.OrderBy(e => e.TenantId, StringComparer.Ordinal)
.ThenBy(e => e.SnapshotId, StringComparer.Ordinal)
.ThenBy(e => e.Artifact, StringComparer.Ordinal)
.ThenBy(e => e.Purl, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<SbomInventoryEvidence>>(list);
}
public Task<bool> ClearInventoryAsync(CancellationToken cancellationToken)
{
_inventoryEvents.Clear();
return Task.FromResult(true);
}
public Task<bool> PublishVersionCreatedAsync(SbomVersionCreatedEvent evt, CancellationToken cancellationToken)
{
var key = $"{evt.SnapshotId}|{evt.TenantId}|{evt.ProjectionHash}";
var added = _events.TryAdd(key, evt);
return Task.FromResult(added);
}
public Task<bool> PublishAssetUpdatedAsync(SbomAssetUpdatedEvent evt, CancellationToken cancellationToken)
{
var key = $"{evt.SnapshotId}|{evt.TenantId}|{evt.ProjectionHash}";
var added = _assetEvents.TryAdd(key, evt);
return Task.FromResult(added);
}
public Task<bool> PublishInventoryAsync(SbomInventoryEvidence evt, CancellationToken cancellationToken)
{
var key = $"{evt.SnapshotId}|{evt.TenantId}|{evt.Artifact}|{evt.Purl}|{evt.Scope}|{evt.RuntimeFlag}";
var added = _inventoryEvents.TryAdd(key, evt);
return Task.FromResult(added);
}
public Task<IReadOnlyList<ResolverCandidate>> ListResolverAsync(CancellationToken cancellationToken)
{
var list = _resolverEvents.Values
.OrderBy(e => e.TenantId, StringComparer.Ordinal)
.ThenBy(e => e.Artifact, StringComparer.Ordinal)
.ThenBy(e => e.Purl, StringComparer.Ordinal)
.ThenBy(e => e.Version, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<ResolverCandidate>>(list);
}
public Task<bool> ClearResolverAsync(CancellationToken cancellationToken)
{
_resolverEvents.Clear();
return Task.FromResult(true);
}
public Task<bool> PublishResolverAsync(ResolverCandidate candidate, CancellationToken cancellationToken)
{
var key = $"{candidate.TenantId}|{candidate.Artifact}|{candidate.Purl}|{candidate.Version}|{candidate.Scope}|{candidate.RuntimeFlag}";
var added = _resolverEvents.TryAdd(key, candidate);
return Task.FromResult(added);
}
}

Some files were not shown because too many files have changed in this diff Show More