stabilizaiton work - projects rework for maintenanceability and ui livening

This commit is contained in:
master
2026-02-03 23:40:04 +02:00
parent 074ce117ba
commit 557feefdc3
3305 changed files with 186813 additions and 107843 deletions

View File

@@ -82,54 +82,3 @@ public interface IReachGraphRepository
ReplayLogEntry entry,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of storing a graph.
/// </summary>
public sealed record StoreResult
{
public required string Digest { get; init; }
public required bool Created { get; init; }
public required string ArtifactDigest { get; init; }
public required int NodeCount { get; init; }
public required int EdgeCount { get; init; }
public required DateTimeOffset StoredAt { get; init; }
}
/// <summary>
/// Summary of a stored graph (without full blob).
/// </summary>
public sealed record ReachGraphSummary
{
public required string Digest { get; init; }
public required string ArtifactDigest { get; init; }
public required int NodeCount { get; init; }
public required int EdgeCount { get; init; }
public required int BlobSizeBytes { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required ReachGraphScope Scope { get; init; }
}
/// <summary>
/// Entry in the replay verification log.
/// </summary>
public sealed record ReplayLogEntry
{
public required string SubgraphDigest { get; init; }
public required ReachGraphInputs InputDigests { get; init; }
public required string ComputedDigest { get; init; }
public required bool Matches { get; init; }
public required string TenantId { get; init; }
public required int DurationMs { get; init; }
public ReplayDivergence? Divergence { get; init; }
}
/// <summary>
/// Describes divergence when replay doesn't match.
/// </summary>
public sealed record ReplayDivergence
{
public required int NodesAdded { get; init; }
public required int NodesRemoved { get; init; }
public required int EdgesChanged { get; init; }
}

View File

@@ -0,0 +1,44 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Dapper;
using Microsoft.Extensions.Logging;
namespace StellaOps.ReachGraph.Persistence;
public sealed partial class PostgresReachGraphRepository
{
/// <inheritdoc />
public async Task<bool> DeleteAsync(
string digest,
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(digest);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
await using var connection = await _dataSource
.OpenConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
DELETE FROM reachgraph.subgraphs
WHERE digest = @Digest
AND tenant_id = @TenantId
RETURNING digest
""";
var command = new CommandDefinition(
sql,
new { Digest = digest, TenantId = tenantId },
cancellationToken: cancellationToken);
var deleted = await connection.QuerySingleOrDefaultAsync<string>(command).ConfigureAwait(false);
if (deleted is not null)
{
_logger.LogInformation("Deleted reachability graph {Digest}", digest);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,44 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Dapper;
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Persistence;
public sealed partial class PostgresReachGraphRepository
{
/// <inheritdoc />
public async Task<ReachGraphMinimal?> GetByDigestAsync(
string digest,
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(digest);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
await using var connection = await _dataSource
.OpenConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT blob
FROM reachgraph.subgraphs
WHERE digest = @Digest
AND tenant_id = @TenantId
""";
var command = new CommandDefinition(
sql,
new { Digest = digest, TenantId = tenantId },
cancellationToken: cancellationToken);
var blob = await connection.QuerySingleOrDefaultAsync<byte[]>(command).ConfigureAwait(false);
if (blob is null)
{
return null;
}
var decompressed = ReachGraphPersistenceCodec.DecompressGzip(blob);
return _serializer.Deserialize(decompressed);
}
}

View File

@@ -0,0 +1,96 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Dapper;
using StellaOps.ReachGraph.Schema;
using System.Text.Json;
namespace StellaOps.ReachGraph.Persistence;
public sealed partial class PostgresReachGraphRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<ReachGraphSummary>> ListByArtifactAsync(
string artifactDigest,
string tenantId,
int limit = 50,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(artifactDigest);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
var effectiveLimit = ClampLimit(limit);
await using var connection = await _dataSource
.OpenConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope
FROM reachgraph.subgraphs
WHERE artifact_digest = @ArtifactDigest
AND tenant_id = @TenantId
ORDER BY created_at DESC
LIMIT @Limit
""";
var command = new CommandDefinition(
sql,
new { ArtifactDigest = artifactDigest, TenantId = tenantId, Limit = effectiveLimit },
cancellationToken: cancellationToken);
var rows = await connection.QueryAsync<dynamic>(command).ConfigureAwait(false);
return rows.Select(row => new ReachGraphSummary
{
Digest = row.digest,
ArtifactDigest = row.artifact_digest,
NodeCount = row.node_count,
EdgeCount = row.edge_count,
BlobSizeBytes = row.blob_size_bytes,
CreatedAt = row.created_at,
Scope = ReachGraphPersistenceCodec.ParseScope((string)row.scope)
}).ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<ReachGraphSummary>> FindByCveAsync(
string cveId,
string tenantId,
int limit = 50,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(cveId);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
var effectiveLimit = ClampLimit(limit);
await using var connection = await _dataSource
.OpenConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope
FROM reachgraph.subgraphs
WHERE scope->'cves' @> @CveJson::jsonb
AND tenant_id = @TenantId
ORDER BY created_at DESC
LIMIT @Limit
""";
var cveJson = JsonSerializer.Serialize(new[] { cveId });
var command = new CommandDefinition(
sql,
new { CveJson = cveJson, TenantId = tenantId, Limit = effectiveLimit },
cancellationToken: cancellationToken);
var rows = await connection.QueryAsync<dynamic>(command).ConfigureAwait(false);
return rows.Select(row => new ReachGraphSummary
{
Digest = row.digest,
ArtifactDigest = row.artifact_digest,
NodeCount = row.node_count,
EdgeCount = row.edge_count,
BlobSizeBytes = row.blob_size_bytes,
CreatedAt = row.created_at,
Scope = ReachGraphPersistenceCodec.ParseScope((string)row.scope)
}).ToList();
}
}

View File

@@ -0,0 +1,55 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Dapper;
using Microsoft.Extensions.Logging;
namespace StellaOps.ReachGraph.Persistence;
public sealed partial class PostgresReachGraphRepository
{
/// <inheritdoc />
public async Task RecordReplayAsync(
ReplayLogEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
await using var connection = await _dataSource
.OpenConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await SetTenantContextAsync(connection, entry.TenantId, cancellationToken).ConfigureAwait(false);
var inputsJson = ReachGraphPersistenceCodec.SerializeInputs(entry.InputDigests);
var divergenceJson = ReachGraphPersistenceCodec.SerializeDivergence(entry.Divergence);
const string sql = """
INSERT INTO reachgraph.replay_log (
subgraph_digest, input_digests, computed_digest, matches,
divergence, tenant_id, duration_ms
)
VALUES (
@SubgraphDigest, @InputDigests::jsonb, @ComputedDigest, @Matches,
@Divergence::jsonb, @TenantId, @DurationMs
)
""";
var command = new CommandDefinition(sql, new
{
entry.SubgraphDigest,
InputDigests = inputsJson,
entry.ComputedDigest,
entry.Matches,
Divergence = divergenceJson,
entry.TenantId,
entry.DurationMs
}, cancellationToken: cancellationToken);
await connection.ExecuteAsync(command).ConfigureAwait(false);
_logger.LogInformation(
"Recorded replay {Result} for {Digest} (computed: {Computed}, {Duration}ms)",
entry.Matches ? "MATCH" : "MISMATCH",
entry.SubgraphDigest,
entry.ComputedDigest,
entry.DurationMs);
}
}

View File

@@ -0,0 +1,82 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Dapper;
using Microsoft.Extensions.Logging;
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Persistence;
public sealed partial class PostgresReachGraphRepository
{
/// <inheritdoc />
public async Task<StoreResult> StoreAsync(
ReachGraphMinimal graph,
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
var digest = _digestComputer.ComputeDigest(graph);
var canonicalBytes = _serializer.SerializeMinimal(graph);
var compressedBlob = ReachGraphPersistenceCodec.CompressGzip(canonicalBytes);
var scopeJson = ReachGraphPersistenceCodec.SerializeScope(graph.Scope);
var provenanceJson = ReachGraphPersistenceCodec.SerializeProvenance(graph.Provenance);
await using var connection = await _dataSource
.OpenConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO reachgraph.subgraphs (
digest, artifact_digest, tenant_id, scope, node_count, edge_count,
blob, blob_size_bytes, provenance
)
VALUES (
@Digest, @ArtifactDigest, @TenantId, @Scope::jsonb, @NodeCount, @EdgeCount,
@Blob, @BlobSizeBytes, @Provenance::jsonb
)
ON CONFLICT (digest) DO NOTHING
RETURNING created_at
""";
var command = new CommandDefinition(sql, new
{
Digest = digest,
ArtifactDigest = graph.Artifact.Digest,
TenantId = tenantId,
Scope = scopeJson,
NodeCount = graph.Nodes.Length,
EdgeCount = graph.Edges.Length,
Blob = compressedBlob,
BlobSizeBytes = compressedBlob.Length,
Provenance = provenanceJson
}, cancellationToken: cancellationToken);
var result = await connection
.QuerySingleOrDefaultAsync<DateTime?>(command)
.ConfigureAwait(false);
var created = result.HasValue;
var storedAt = result.HasValue
? new DateTimeOffset(DateTime.SpecifyKind(result.Value, DateTimeKind.Utc))
: _timeProvider.GetUtcNow();
_logger.LogInformation(
"{Action} reachability graph {Digest} for artifact {Artifact}",
created ? "Stored" : "Found existing",
digest,
graph.Artifact.Digest);
return new StoreResult
{
Digest = digest,
Created = created,
ArtifactDigest = graph.Artifact.Digest,
NodeCount = graph.Nodes.Length,
EdgeCount = graph.Edges.Length,
StoredAt = storedAt
};
}
}

View File

@@ -0,0 +1,18 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Npgsql;
namespace StellaOps.ReachGraph.Persistence;
public sealed partial class PostgresReachGraphRepository
{
private static async Task SetTenantContextAsync(
NpgsqlConnection connection,
string tenantId,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = "SELECT set_config('app.tenant_id', @TenantId, false);";
command.Parameters.AddWithValue("TenantId", tenantId);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -1,34 +1,23 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Dapper;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.ReachGraph.Hashing;
using StellaOps.ReachGraph.Schema;
using StellaOps.ReachGraph.Serialization;
using System.Collections.Immutable;
using System.IO.Compression;
using System.Text.Json;
namespace StellaOps.ReachGraph.Persistence;
/// <summary>
/// PostgreSQL implementation of the ReachGraph repository.
/// </summary>
public sealed class PostgresReachGraphRepository : IReachGraphRepository
public sealed partial class PostgresReachGraphRepository : IReachGraphRepository
{
private const int MaxLimit = 100;
private readonly NpgsqlDataSource _dataSource;
private readonly CanonicalReachGraphSerializer _serializer;
private readonly ReachGraphDigestComputer _digestComputer;
private readonly ILogger<PostgresReachGraphRepository> _logger;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public PostgresReachGraphRepository(
NpgsqlDataSource dataSource,
CanonicalReachGraphSerializer serializer,
@@ -43,307 +32,13 @@ public sealed class PostgresReachGraphRepository : IReachGraphRepository
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<StoreResult> StoreAsync(
ReachGraphMinimal graph,
string tenantId,
CancellationToken cancellationToken = default)
internal static int ClampLimit(int limit)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
var digest = _digestComputer.ComputeDigest(graph);
var canonicalBytes = _serializer.SerializeMinimal(graph);
var compressedBlob = CompressGzip(canonicalBytes);
var scopeJson = JsonSerializer.Serialize(new
if (limit <= 0)
{
entrypoints = graph.Scope.Entrypoints,
selectors = graph.Scope.Selectors,
cves = graph.Scope.Cves
}, JsonOptions);
var provenanceJson = JsonSerializer.Serialize(new
{
intoto = graph.Provenance.Intoto,
inputs = graph.Provenance.Inputs,
computedAt = graph.Provenance.ComputedAt,
analyzer = graph.Provenance.Analyzer
}, JsonOptions);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContext(connection, tenantId, cancellationToken);
const string sql = """
INSERT INTO reachgraph.subgraphs (
digest, artifact_digest, tenant_id, scope, node_count, edge_count,
blob, blob_size_bytes, provenance
)
VALUES (
@Digest, @ArtifactDigest, @TenantId, @Scope::jsonb, @NodeCount, @EdgeCount,
@Blob, @BlobSizeBytes, @Provenance::jsonb
)
ON CONFLICT (digest) DO NOTHING
RETURNING created_at
""";
var result = await connection.QuerySingleOrDefaultAsync<DateTimeOffset?>(sql, new
{
Digest = digest,
ArtifactDigest = graph.Artifact.Digest,
TenantId = tenantId,
Scope = scopeJson,
NodeCount = graph.Nodes.Length,
EdgeCount = graph.Edges.Length,
Blob = compressedBlob,
BlobSizeBytes = compressedBlob.Length,
Provenance = provenanceJson
});
var created = result.HasValue;
var storedAt = result ?? _timeProvider.GetUtcNow();
_logger.LogInformation(
"{Action} reachability graph {Digest} for artifact {Artifact}",
created ? "Stored" : "Found existing",
digest,
graph.Artifact.Digest);
return new StoreResult
{
Digest = digest,
Created = created,
ArtifactDigest = graph.Artifact.Digest,
NodeCount = graph.Nodes.Length,
EdgeCount = graph.Edges.Length,
StoredAt = storedAt
};
}
/// <inheritdoc />
public async Task<ReachGraphMinimal?> GetByDigestAsync(
string digest,
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(digest);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContext(connection, tenantId, cancellationToken);
const string sql = """
SELECT blob
FROM reachgraph.subgraphs
WHERE digest = @Digest
""";
var blob = await connection.QuerySingleOrDefaultAsync<byte[]>(sql, new { Digest = digest });
if (blob is null)
{
return null;
throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be greater than zero.");
}
var decompressed = DecompressGzip(blob);
return _serializer.Deserialize(decompressed);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ReachGraphSummary>> ListByArtifactAsync(
string artifactDigest,
string tenantId,
int limit = 50,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(artifactDigest);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContext(connection, tenantId, cancellationToken);
const string sql = """
SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope
FROM reachgraph.subgraphs
WHERE artifact_digest = @ArtifactDigest
ORDER BY created_at DESC
LIMIT @Limit
""";
var rows = await connection.QueryAsync<dynamic>(sql, new { ArtifactDigest = artifactDigest, Limit = limit });
return rows.Select(row => new ReachGraphSummary
{
Digest = row.digest,
ArtifactDigest = row.artifact_digest,
NodeCount = row.node_count,
EdgeCount = row.edge_count,
BlobSizeBytes = row.blob_size_bytes,
CreatedAt = row.created_at,
Scope = ParseScope((string)row.scope)
}).ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<ReachGraphSummary>> FindByCveAsync(
string cveId,
string tenantId,
int limit = 50,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(cveId);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContext(connection, tenantId, cancellationToken);
const string sql = """
SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope
FROM reachgraph.subgraphs
WHERE scope->'cves' @> @CveJson::jsonb
ORDER BY created_at DESC
LIMIT @Limit
""";
var cveJson = JsonSerializer.Serialize(new[] { cveId });
var rows = await connection.QueryAsync<dynamic>(sql, new { CveJson = cveJson, Limit = limit });
return rows.Select(row => new ReachGraphSummary
{
Digest = row.digest,
ArtifactDigest = row.artifact_digest,
NodeCount = row.node_count,
EdgeCount = row.edge_count,
BlobSizeBytes = row.blob_size_bytes,
CreatedAt = row.created_at,
Scope = ParseScope((string)row.scope)
}).ToList();
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(
string digest,
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(digest);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContext(connection, tenantId, cancellationToken);
// Using DELETE for now; could be soft-delete with deleted_at column
const string sql = """
DELETE FROM reachgraph.subgraphs
WHERE digest = @Digest
RETURNING digest
""";
var deleted = await connection.QuerySingleOrDefaultAsync<string>(sql, new { Digest = digest });
if (deleted is not null)
{
_logger.LogInformation("Deleted reachability graph {Digest}", digest);
return true;
}
return false;
}
/// <inheritdoc />
public async Task RecordReplayAsync(
ReplayLogEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContext(connection, entry.TenantId, cancellationToken);
var inputsJson = JsonSerializer.Serialize(entry.InputDigests, JsonOptions);
var divergenceJson = entry.Divergence is not null
? JsonSerializer.Serialize(entry.Divergence, JsonOptions)
: null;
const string sql = """
INSERT INTO reachgraph.replay_log (
subgraph_digest, input_digests, computed_digest, matches,
divergence, tenant_id, duration_ms
)
VALUES (
@SubgraphDigest, @InputDigests::jsonb, @ComputedDigest, @Matches,
@Divergence::jsonb, @TenantId, @DurationMs
)
""";
await connection.ExecuteAsync(sql, new
{
entry.SubgraphDigest,
InputDigests = inputsJson,
entry.ComputedDigest,
entry.Matches,
Divergence = divergenceJson,
entry.TenantId,
entry.DurationMs
});
_logger.LogInformation(
"Recorded replay {Result} for {Digest} (computed: {Computed}, {Duration}ms)",
entry.Matches ? "MATCH" : "MISMATCH",
entry.SubgraphDigest,
entry.ComputedDigest,
entry.DurationMs);
}
private static async Task SetTenantContext(
NpgsqlConnection connection,
string tenantId,
CancellationToken cancellationToken)
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SET LOCAL app.tenant_id = @TenantId";
cmd.Parameters.AddWithValue("TenantId", tenantId);
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
private static byte[] CompressGzip(byte[] data)
{
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize, leaveOpen: true))
{
gzip.Write(data);
}
return output.ToArray();
}
private static byte[] DecompressGzip(byte[] compressed)
{
using var input = new MemoryStream(compressed);
using var gzip = new GZipStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
gzip.CopyTo(output);
return output.ToArray();
}
private static ReachGraphScope ParseScope(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var entrypoints = root.TryGetProperty("entrypoints", out var ep)
? ep.EnumerateArray().Select(e => e.GetString()!).ToImmutableArray()
: ImmutableArray<string>.Empty;
var selectors = root.TryGetProperty("selectors", out var sel)
? sel.EnumerateArray().Select(s => s.GetString()!).ToImmutableArray()
: ImmutableArray<string>.Empty;
ImmutableArray<string>? cves = null;
if (root.TryGetProperty("cves", out var cvesElem) && cvesElem.ValueKind == JsonValueKind.Array)
{
cves = cvesElem.EnumerateArray().Select(c => c.GetString()!).ToImmutableArray();
}
return new ReachGraphScope(entrypoints, selectors, cves);
return Math.Min(limit, MaxLimit);
}
}

View File

@@ -0,0 +1,96 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
using System.Collections.Immutable;
using System.IO.Compression;
using System.Text.Json;
namespace StellaOps.ReachGraph.Persistence;
internal static class ReachGraphPersistenceCodec
{
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
internal static string SerializeScope(ReachGraphScope scope)
{
ArgumentNullException.ThrowIfNull(scope);
return JsonSerializer.Serialize(new
{
entrypoints = scope.Entrypoints,
selectors = scope.Selectors,
cves = scope.Cves
}, _jsonOptions);
}
internal static string SerializeProvenance(ReachGraphProvenance provenance)
{
ArgumentNullException.ThrowIfNull(provenance);
return JsonSerializer.Serialize(new
{
intoto = provenance.Intoto,
inputs = provenance.Inputs,
computedAt = provenance.ComputedAt,
analyzer = provenance.Analyzer
}, _jsonOptions);
}
internal static string SerializeInputs(ReachGraphInputs inputs)
{
ArgumentNullException.ThrowIfNull(inputs);
return JsonSerializer.Serialize(inputs, _jsonOptions);
}
internal static string? SerializeDivergence(ReplayDivergence? divergence)
=> divergence is null ? null : JsonSerializer.Serialize(divergence, _jsonOptions);
internal static byte[] CompressGzip(byte[] data)
{
ArgumentNullException.ThrowIfNull(data);
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize, leaveOpen: true))
{
gzip.Write(data);
}
return output.ToArray();
}
internal static byte[] DecompressGzip(byte[] compressed)
{
ArgumentNullException.ThrowIfNull(compressed);
using var input = new MemoryStream(compressed);
using var gzip = new GZipStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
gzip.CopyTo(output);
return output.ToArray();
}
internal static ReachGraphScope ParseScope(string json)
{
ArgumentException.ThrowIfNullOrEmpty(json);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var entrypoints = root.TryGetProperty("entrypoints", out var ep)
? ep.EnumerateArray().Select(e => e.GetString()!).ToImmutableArray()
: ImmutableArray<string>.Empty;
var selectors = root.TryGetProperty("selectors", out var sel)
? sel.EnumerateArray().Select(s => s.GetString()!).ToImmutableArray()
: ImmutableArray<string>.Empty;
ImmutableArray<string>? cves = null;
if (root.TryGetProperty("cves", out var cvesElem) && cvesElem.ValueKind == JsonValueKind.Array)
{
cves = cvesElem.EnumerateArray().Select(c => c.GetString()!).ToImmutableArray();
}
return new ReachGraphScope(entrypoints, selectors, cves);
}
}

View File

@@ -0,0 +1,18 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Persistence;
/// <summary>
/// Summary of a stored graph (without full blob).
/// </summary>
public sealed record ReachGraphSummary
{
public required string Digest { get; init; }
public required string ArtifactDigest { get; init; }
public required int NodeCount { get; init; }
public required int EdgeCount { get; init; }
public required int BlobSizeBytes { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required ReachGraphScope Scope { get; init; }
}

View File

@@ -0,0 +1,12 @@
// Licensed to StellaOps under the BUSL-1.1 license.
namespace StellaOps.ReachGraph.Persistence;
/// <summary>
/// Describes divergence when replay doesn't match.
/// </summary>
public sealed record ReplayDivergence
{
public required int NodesAdded { get; init; }
public required int NodesRemoved { get; init; }
public required int EdgesChanged { get; init; }
}

View File

@@ -0,0 +1,18 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Persistence;
/// <summary>
/// Entry in the replay verification log.
/// </summary>
public sealed record ReplayLogEntry
{
public required string SubgraphDigest { get; init; }
public required ReachGraphInputs InputDigests { get; init; }
public required string ComputedDigest { get; init; }
public required bool Matches { get; init; }
public required string TenantId { get; init; }
public required int DurationMs { get; init; }
public ReplayDivergence? Divergence { get; init; }
}

View File

@@ -0,0 +1,15 @@
// Licensed to StellaOps under the BUSL-1.1 license.
namespace StellaOps.ReachGraph.Persistence;
/// <summary>
/// Result of storing a graph.
/// </summary>
public sealed record StoreResult
{
public required string Digest { get; init; }
public required bool Created { get; init; }
public required string ArtifactDigest { get; init; }
public required int NodeCount { get; init; }
public required int EdgeCount { get; init; }
public required DateTimeOffset StoredAt { get; init; }
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0104-T | DONE | Revalidated 2026-01-08; test coverage audit for ReachGraph.Persistence. |
| AUDIT-0104-A | TODO | Pending approval (revalidated 2026-01-08). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-08 | DONE | Enforced tenant filters in list/get/delete queries, added Intent traits for tests; `dotnet test src/__Libraries/__Tests/StellaOps.ReachGraph.Persistence.Tests/StellaOps.ReachGraph.Persistence.Tests.csproj` passed 2026-02-03. |