Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -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>();
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user