Gaps fill up, fixes, ui restructuring

This commit is contained in:
master
2026-02-19 22:10:54 +02:00
parent b5829dce5c
commit 04cacdca8a
331 changed files with 42859 additions and 2174 deletions

View File

@@ -46,6 +46,7 @@ public static class PolicyPersistenceExtensions
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
@@ -79,6 +80,7 @@ public static class PolicyPersistenceExtensions
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();

View File

@@ -0,0 +1,155 @@
-- Policy Schema Migration 005: Advisory source impact/conflict projection
-- Sprint: SPRINT_20260219_008
-- Task: BE8-05
CREATE TABLE IF NOT EXISTS policy.advisory_source_impacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
source_key TEXT NOT NULL,
source_family TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
environment TEXT NOT NULL DEFAULT '',
impacted_decisions_count INT NOT NULL DEFAULT 0 CHECK (impacted_decisions_count >= 0),
impact_severity TEXT NOT NULL DEFAULT 'none' CHECK (impact_severity IN ('none', 'low', 'medium', 'high', 'critical')),
last_decision_at TIMESTAMPTZ,
decision_refs JSONB NOT NULL DEFAULT '[]',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system'
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_advisory_source_impacts_scope
ON policy.advisory_source_impacts (tenant_id, source_key, source_family, region, environment);
CREATE INDEX IF NOT EXISTS idx_advisory_source_impacts_lookup
ON policy.advisory_source_impacts (tenant_id, source_key, impact_severity, updated_at DESC);
DROP TRIGGER IF EXISTS trg_advisory_source_impacts_updated_at ON policy.advisory_source_impacts;
CREATE TRIGGER trg_advisory_source_impacts_updated_at
BEFORE UPDATE ON policy.advisory_source_impacts
FOR EACH ROW
EXECUTE FUNCTION policy.update_updated_at();
ALTER TABLE policy.advisory_source_impacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY advisory_source_impacts_tenant_isolation ON policy.advisory_source_impacts
FOR ALL
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
CREATE TABLE IF NOT EXISTS policy.advisory_source_conflicts (
conflict_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
source_key TEXT NOT NULL,
source_family TEXT NOT NULL DEFAULT '',
advisory_id TEXT NOT NULL,
paired_source_key TEXT,
conflict_type TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'dismissed')),
description TEXT NOT NULL DEFAULT '',
first_detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
details_json JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system'
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_advisory_source_conflicts_open
ON policy.advisory_source_conflicts (tenant_id, source_key, advisory_id, conflict_type, COALESCE(paired_source_key, ''))
WHERE status = 'open';
CREATE INDEX IF NOT EXISTS idx_advisory_source_conflicts_lookup
ON policy.advisory_source_conflicts (tenant_id, source_key, status, severity, last_detected_at DESC);
CREATE INDEX IF NOT EXISTS idx_advisory_source_conflicts_advisory
ON policy.advisory_source_conflicts (tenant_id, advisory_id, status);
DROP TRIGGER IF EXISTS trg_advisory_source_conflicts_updated_at ON policy.advisory_source_conflicts;
CREATE TRIGGER trg_advisory_source_conflicts_updated_at
BEFORE UPDATE ON policy.advisory_source_conflicts
FOR EACH ROW
EXECUTE FUNCTION policy.update_updated_at();
ALTER TABLE policy.advisory_source_conflicts ENABLE ROW LEVEL SECURITY;
CREATE POLICY advisory_source_conflicts_tenant_isolation ON policy.advisory_source_conflicts
FOR ALL
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
-- Best-effort backfill from legacy policy.conflicts rows that encode source scope as source:<key>.
INSERT INTO policy.advisory_source_conflicts (
tenant_id,
source_key,
source_family,
advisory_id,
paired_source_key,
conflict_type,
severity,
status,
description,
first_detected_at,
last_detected_at,
details_json,
updated_by
)
SELECT
c.tenant_id,
split_part(c.affected_scope, ':', 2) AS source_key,
COALESCE(c.metadata->>'source_family', '') AS source_family,
COALESCE(c.metadata->>'advisory_id', 'unknown') AS advisory_id,
NULLIF(c.metadata->>'paired_source_key', '') AS paired_source_key,
c.conflict_type,
c.severity,
c.status,
c.description,
c.created_at AS first_detected_at,
COALESCE(c.resolved_at, c.created_at) AS last_detected_at,
c.metadata AS details_json,
'migration-005-backfill'
FROM policy.conflicts c
WHERE c.affected_scope LIKE 'source:%'
ON CONFLICT DO NOTHING;
INSERT INTO policy.advisory_source_impacts (
tenant_id,
source_key,
source_family,
impacted_decisions_count,
impact_severity,
last_decision_at,
decision_refs,
updated_by
)
SELECT
c.tenant_id,
c.source_key,
c.source_family,
COUNT(*)::INT AS impacted_decisions_count,
CASE MAX(
CASE c.severity
WHEN 'critical' THEN 4
WHEN 'high' THEN 3
WHEN 'medium' THEN 2
WHEN 'low' THEN 1
ELSE 0
END)
WHEN 4 THEN 'critical'
WHEN 3 THEN 'high'
WHEN 2 THEN 'medium'
WHEN 1 THEN 'low'
ELSE 'none'
END AS impact_severity,
MAX(c.last_detected_at) AS last_decision_at,
'[]'::jsonb AS decision_refs,
'migration-005-backfill'
FROM policy.advisory_source_conflicts c
WHERE c.status = 'open'
GROUP BY c.tenant_id, c.source_key, c.source_family
ON CONFLICT (tenant_id, source_key, source_family, region, environment) DO UPDATE
SET
impacted_decisions_count = EXCLUDED.impacted_decisions_count,
impact_severity = EXCLUDED.impact_severity,
last_decision_at = EXCLUDED.last_decision_at,
updated_by = EXCLUDED.updated_by;

View File

@@ -0,0 +1,228 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed read model for advisory-source policy facts.
/// </summary>
public sealed class AdvisorySourcePolicyReadRepository
: RepositoryBase<PolicyDataSource>, IAdvisorySourcePolicyReadRepository
{
public AdvisorySourcePolicyReadRepository(
PolicyDataSource dataSource,
ILogger<AdvisorySourcePolicyReadRepository> logger)
: base(dataSource, logger)
{
}
public async Task<AdvisorySourceImpactSnapshot> GetImpactAsync(
string tenantId,
string sourceKey,
string? region,
string? environment,
string? sourceFamily,
CancellationToken cancellationToken = default)
{
const string sql = """
WITH filtered AS (
SELECT
source_key,
source_family,
region,
environment,
impacted_decisions_count,
impact_severity,
last_decision_at,
updated_at,
decision_refs
FROM policy.advisory_source_impacts
WHERE tenant_id = @tenant_id
AND lower(source_key) = lower(@source_key)
AND (@region IS NULL OR lower(region) = lower(@region))
AND (@environment IS NULL OR lower(environment) = lower(@environment))
AND (@source_family IS NULL OR lower(source_family) = lower(@source_family))
),
aggregate_row AS (
SELECT
@source_key AS source_key,
@source_family AS source_family_filter,
@region AS region_filter,
@environment AS environment_filter,
COALESCE(SUM(impacted_decisions_count), 0)::INT AS impacted_decisions_count,
COALESCE(MAX(
CASE impact_severity
WHEN 'critical' THEN 4
WHEN 'high' THEN 3
WHEN 'medium' THEN 2
WHEN 'low' THEN 1
ELSE 0
END
), 0) AS severity_rank,
MAX(last_decision_at) AS last_decision_at,
MAX(updated_at) AS updated_at,
COALESCE(
(SELECT decision_refs FROM filtered ORDER BY updated_at DESC NULLS LAST LIMIT 1),
'[]'::jsonb
)::TEXT AS decision_refs_json
FROM filtered
)
SELECT
source_key,
source_family_filter,
region_filter,
environment_filter,
impacted_decisions_count,
CASE severity_rank
WHEN 4 THEN 'critical'
WHEN 3 THEN 'high'
WHEN 2 THEN 'medium'
WHEN 1 THEN 'low'
ELSE 'none'
END AS impact_severity,
last_decision_at,
updated_at,
decision_refs_json
FROM aggregate_row;
""";
var normalizedSourceKey = NormalizeRequired(sourceKey, nameof(sourceKey));
var normalizedRegion = NormalizeOptional(region);
var normalizedEnvironment = NormalizeOptional(environment);
var normalizedSourceFamily = NormalizeOptional(sourceFamily);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "source_key", normalizedSourceKey);
AddParameter(command, "region", normalizedRegion);
AddParameter(command, "environment", normalizedEnvironment);
AddParameter(command, "source_family", normalizedSourceFamily);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return new AdvisorySourceImpactSnapshot(
SourceKey: reader.GetString(0),
SourceFamily: GetNullableString(reader, 1),
Region: GetNullableString(reader, 2),
Environment: GetNullableString(reader, 3),
ImpactedDecisionsCount: reader.GetInt32(4),
ImpactSeverity: reader.GetString(5),
LastDecisionAt: GetNullableDateTimeOffset(reader, 6),
UpdatedAt: GetNullableDateTimeOffset(reader, 7),
DecisionRefsJson: reader.GetString(8));
}
public async Task<AdvisorySourceConflictPage> ListConflictsAsync(
string tenantId,
string sourceKey,
string? status,
int limit,
int offset,
CancellationToken cancellationToken = default)
{
const string countSql = """
SELECT COUNT(*)::INT
FROM policy.advisory_source_conflicts
WHERE tenant_id = @tenant_id
AND lower(source_key) = lower(@source_key)
AND (@status IS NULL OR status = @status)
""";
const string listSql = """
SELECT
conflict_id,
advisory_id,
paired_source_key,
conflict_type,
severity,
status,
description,
first_detected_at,
last_detected_at,
resolved_at,
details_json::TEXT
FROM policy.advisory_source_conflicts
WHERE tenant_id = @tenant_id
AND lower(source_key) = lower(@source_key)
AND (@status IS NULL OR status = @status)
ORDER BY
CASE severity
WHEN 'critical' THEN 4
WHEN 'high' THEN 3
WHEN 'medium' THEN 2
WHEN 'low' THEN 1
ELSE 0
END DESC,
last_detected_at DESC,
conflict_id
LIMIT @limit
OFFSET @offset
""";
var normalizedSourceKey = NormalizeRequired(sourceKey, nameof(sourceKey));
var normalizedStatus = NormalizeOptional(status);
var normalizedLimit = Math.Clamp(limit, 1, 200);
var normalizedOffset = Math.Max(offset, 0);
var totalCount = await ExecuteScalarAsync<int>(
tenantId,
countSql,
command =>
{
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "source_key", normalizedSourceKey);
AddParameter(command, "status", normalizedStatus);
},
cancellationToken).ConfigureAwait(false);
var items = await QueryAsync(
tenantId,
listSql,
command =>
{
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "source_key", normalizedSourceKey);
AddParameter(command, "status", normalizedStatus);
AddParameter(command, "limit", normalizedLimit);
AddParameter(command, "offset", normalizedOffset);
},
MapConflict,
cancellationToken).ConfigureAwait(false);
return new AdvisorySourceConflictPage(items, totalCount);
}
private static AdvisorySourceConflictRecord MapConflict(NpgsqlDataReader reader)
{
return new AdvisorySourceConflictRecord(
ConflictId: reader.GetGuid(0),
AdvisoryId: reader.GetString(1),
PairedSourceKey: GetNullableString(reader, 2),
ConflictType: reader.GetString(3),
Severity: reader.GetString(4),
Status: reader.GetString(5),
Description: reader.GetString(6),
FirstDetectedAt: reader.GetFieldValue<DateTimeOffset>(7),
LastDetectedAt: reader.GetFieldValue<DateTimeOffset>(8),
ResolvedAt: GetNullableDateTimeOffset(reader, 9),
DetailsJson: reader.GetString(10));
}
private static string NormalizeRequired(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"{parameterName} is required.", parameterName);
}
return value.Trim();
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -0,0 +1,51 @@
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// Read-model access for Advisory Sources policy-owned impact and conflict facts.
/// </summary>
public interface IAdvisorySourcePolicyReadRepository
{
Task<AdvisorySourceImpactSnapshot> GetImpactAsync(
string tenantId,
string sourceKey,
string? region,
string? environment,
string? sourceFamily,
CancellationToken cancellationToken = default);
Task<AdvisorySourceConflictPage> ListConflictsAsync(
string tenantId,
string sourceKey,
string? status,
int limit,
int offset,
CancellationToken cancellationToken = default);
}
public sealed record AdvisorySourceImpactSnapshot(
string SourceKey,
string? SourceFamily,
string? Region,
string? Environment,
int ImpactedDecisionsCount,
string ImpactSeverity,
DateTimeOffset? LastDecisionAt,
DateTimeOffset? UpdatedAt,
string DecisionRefsJson);
public sealed record AdvisorySourceConflictRecord(
Guid ConflictId,
string AdvisoryId,
string? PairedSourceKey,
string ConflictType,
string Severity,
string Status,
string Description,
DateTimeOffset FirstDetectedAt,
DateTimeOffset LastDetectedAt,
DateTimeOffset? ResolvedAt,
string DetailsJson);
public sealed record AdvisorySourceConflictPage(
IReadOnlyList<AdvisorySourceConflictRecord> Items,
int TotalCount);

View File

@@ -48,6 +48,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
@@ -81,6 +82,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();