feat(notify,notifier): postgres durable runtime base cutover

Sprint SPRINT_20260415_002_DOCS_notify_notifier_real_backend_cutover.

Base durable storage wiring for both Notify and Notifier:
- NotifyDbContext + EF migrations (002 pack_approvals, 003 operator_override)
- Pack approval / operator override / retention / tenant isolation repos
- Notifier worker Postgres repository adapters (audit, channel, delivery,
  inbox, localization, lock, rule, template) + runtime service base
- Durable runtime fixture + integration test scaffolding
- WebService compat shims for pack approval, operator override, throttle

Sub-sprints _008 (suppression), _009 (escalation), _010 (quiet hours),
_011 (security/deadletter) land as follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-19 14:35:30 +03:00
parent 577a56ebc0
commit 6b89bd5652
58 changed files with 4849 additions and 77 deletions

View File

@@ -165,15 +165,17 @@ public static class ObservabilityEndpoints
// Dead letter handlers
private static async Task<IResult> GetDeadLetters(
string tenantId,
[FromQuery] int limit,
[FromQuery] int offset,
[FromQuery] int? limit,
[FromQuery] int? offset,
[FromServices] IDeadLetterHandler handler,
CancellationToken ct)
{
var resolvedLimit = limit.GetValueOrDefault();
var resolvedOffset = offset.GetValueOrDefault();
var entries = await handler.GetEntriesAsync(
tenantId,
limit: limit > 0 ? limit : 100,
offset: offset,
limit: resolvedLimit > 0 ? resolvedLimit : 100,
offset: resolvedOffset,
ct: ct);
return Results.Ok(entries);
}
@@ -229,11 +231,12 @@ public static class ObservabilityEndpoints
private static async Task<IResult> PurgeDeadLetters(
string tenantId,
[FromQuery] int olderThanDays,
[FromQuery] int? olderThanDays,
[FromServices] IDeadLetterHandler handler,
CancellationToken ct)
{
var olderThan = TimeSpan.FromDays(olderThanDays > 0 ? olderThanDays : 7);
var resolvedOlderThanDays = olderThanDays.GetValueOrDefault();
var olderThan = TimeSpan.FromDays(resolvedOlderThanDays > 0 ? resolvedOlderThanDays : 7);
var count = await handler.PurgeAsync(tenantId, olderThan, ct);
return Results.Ok(new { purged = count });
}
@@ -487,7 +490,7 @@ internal static class DeadLetterHandlerCompatExtensions
this IDeadLetterHandler handler,
string tenantId,
TimeSpan olderThan,
CancellationToken ct) => Task.FromResult(0);
CancellationToken ct) => handler.PurgeAsync(tenantId, olderThan, ct);
}
internal static class RetentionPolicyServiceCompatExtensions

View File

@@ -32,6 +32,7 @@ using Contracts = StellaOps.Notifier.WebService.Contracts;
using WorkerTemplateService = StellaOps.Notifier.Worker.Templates.INotifyTemplateService;
using WorkerTemplateRenderer = StellaOps.Notifier.Worker.Dispatch.INotifyTemplateRenderer;
using StellaOps.Notify.Models;
using StellaOps.Notify.Persistence.Extensions;
using StellaOps.Notify.Queue;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Cryptography;
@@ -53,6 +54,9 @@ builder.Configuration
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
builder.Services.AddSingleton<ICryptoHmac, DefaultCryptoHmac>();
var postgresSection = builder.Configuration.GetSection("notifier:storage:postgres");
builder.Services.AddNotifyPersistence(builder.Configuration, postgresSection.Path);
// Core correlation engine registrations required by incident and escalation flows.
builder.Services.AddCorrelationServices(builder.Configuration);
@@ -60,22 +64,17 @@ builder.Services.AddCorrelationServices(builder.Configuration);
builder.Services.AddSingleton<StellaOps.Notify.Engine.INotifyRuleEvaluator, StellaOps.Notifier.Worker.Processing.DefaultNotifyRuleEvaluator>();
StellaOps.Notifier.Worker.Simulation.SimulationServiceExtensions.AddSimulationServices(builder.Services, builder.Configuration);
// Fallback no-op event queue for environments that do not configure a real backend.
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
if (isTesting)
{
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
}
else
{
builder.Services.AddNotifyEventQueue(builder.Configuration, "notifier:queue");
}
// In-memory storage (document store removed)
builder.Services.AddSingleton<INotifyChannelRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyRuleRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyTemplateRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyDeliveryRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyAuditRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyLockRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<IInAppInboxStore, InMemoryInboxStore>();
builder.Services.AddSingleton<INotifyInboxRepository, InMemoryInboxStore>();
builder.Services.AddSingleton<INotifyLocalizationRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyPackApprovalRepository, InMemoryPackApprovalRepository>();
builder.Services.AddSingleton<INotifyThrottleConfigRepository, InMemoryThrottleConfigRepository>();
builder.Services.AddSingleton<INotifyOperatorOverrideRepository, InMemoryOperatorOverrideRepository>();
builder.Services.AddDurableNotifyWorkerStorage();
builder.Services.AddScoped<INotifyPackApprovalRepository, PostgresPackApprovalRepository>();
builder.Services.AddSingleton<INotifyQuietHoursRepository, InMemoryQuietHoursRepository>();
builder.Services.AddSingleton<INotifyMaintenanceWindowRepository, InMemoryMaintenanceWindowRepository>();
builder.Services.AddSingleton<INotifyEscalationPolicyRepository, InMemoryEscalationPolicyRepository>();
@@ -84,10 +83,38 @@ builder.Services.AddSingleton<INotifyOnCallScheduleRepository, InMemoryOnCallSch
// Correlation suppression services backing /api/v2/throttles, /api/v2/quiet-hours, /api/v2/overrides.
builder.Services.Configure<SuppressionAuditOptions>(builder.Configuration.GetSection(SuppressionAuditOptions.SectionName));
builder.Services.Configure<OperatorOverrideOptions>(builder.Configuration.GetSection(OperatorOverrideOptions.SectionName));
builder.Services.AddSingleton<ISuppressionAuditLogger, InMemorySuppressionAuditLogger>();
builder.Services.AddSingleton<IThrottleConfigurationService, InMemoryThrottleConfigurationService>();
builder.Services.AddSingleton<IQuietHoursCalendarService, InMemoryQuietHoursCalendarService>();
builder.Services.AddSingleton<IOperatorOverrideService, InMemoryOperatorOverrideService>();
if (isTesting)
{
builder.Services.AddSingleton<INotifyThrottleConfigRepository, InMemoryThrottleConfigRepository>();
builder.Services.AddSingleton<INotifyOperatorOverrideRepository, InMemoryOperatorOverrideRepository>();
builder.Services.AddSingleton<ISuppressionAuditLogger, InMemorySuppressionAuditLogger>();
builder.Services.AddSingleton<IThrottleConfigurationService, InMemoryThrottleConfigurationService>();
builder.Services.AddSingleton<IOperatorOverrideService, InMemoryOperatorOverrideService>();
}
else
{
builder.Services.RemoveAll<INotifyQuietHoursRepository>();
builder.Services.RemoveAll<INotifyMaintenanceWindowRepository>();
builder.Services.RemoveAll<IQuietHoursCalendarService>();
builder.Services.RemoveAll<IQuietHoursEvaluator>();
builder.Services.RemoveAll<INotifyThrottleConfigRepository>();
builder.Services.RemoveAll<INotifyOperatorOverrideRepository>();
builder.Services.RemoveAll<ISuppressionAuditLogger>();
builder.Services.RemoveAll<IThrottleConfigurationService>();
builder.Services.RemoveAll<IOperatorOverrideService>();
builder.Services.AddSingleton<INotifyQuietHoursRepository, PostgresNotifyQuietHoursRepository>();
builder.Services.AddSingleton<INotifyMaintenanceWindowRepository, PostgresNotifyMaintenanceWindowRepository>();
builder.Services.AddSingleton<IQuietHoursCalendarService, PostgresQuietHoursCalendarService>();
builder.Services.AddSingleton<IQuietHoursEvaluator, PostgresQuietHoursEvaluator>();
builder.Services.AddScoped<INotifyThrottleConfigRepository, PostgresNotifyThrottleConfigRepository>();
builder.Services.AddScoped<INotifyOperatorOverrideRepository, PostgresNotifyOperatorOverrideRepository>();
builder.Services.AddScoped<ISuppressionAuditLogger, PostgresSuppressionAuditLogger>();
builder.Services.AddScoped<IThrottleConfigurationService, PostgresThrottleConfigurationService>();
builder.Services.AddScoped<IOperatorOverrideService, PostgresOperatorOverrideService>();
}
// Template service with enhanced renderer (worker contracts)
builder.Services.AddTemplateServices(options =>
@@ -118,12 +145,30 @@ builder.Services.AddSingleton<IRetentionPolicyService, DefaultRetentionPolicySer
// Escalation and on-call services
builder.Services.AddEscalationServices(builder.Configuration);
if (!isTesting)
{
builder.Services.RemoveAll<IEscalationPolicyService>();
builder.Services.RemoveAll<IOnCallScheduleService>();
builder.Services.RemoveAll<INotifyEscalationPolicyRepository>();
builder.Services.RemoveAll<INotifyOnCallScheduleRepository>();
builder.Services.AddSingleton<IEscalationPolicyService, PostgresEscalationPolicyService>();
builder.Services.AddSingleton<IOnCallScheduleService, PostgresOnCallScheduleService>();
builder.Services.AddSingleton<INotifyEscalationPolicyRepository, ServiceBackedNotifyEscalationPolicyRepository>();
builder.Services.AddSingleton<INotifyOnCallScheduleRepository, ServiceBackedNotifyOnCallScheduleRepository>();
}
// Storm breaker, localization, and fallback services
builder.Services.AddStormBreakerServices(builder.Configuration);
// Security services (signing, webhook validation, HTML sanitization, tenant isolation)
builder.Services.AddNotifierSecurityServices(builder.Configuration);
if (!isTesting)
{
builder.Services.AddPostgresNotifyAdminRuntimeServices();
}
// Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment)
builder.Services.AddNotifierTenancy(builder.Configuration);
@@ -428,6 +473,7 @@ app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
string packId,
PackApprovalAckRequest request,
INotifyLockRepository locks,
INotifyPackApprovalRepository packApprovals,
INotifyAuditRepository audit,
TimeProvider timeProvider) =>
{
@@ -456,6 +502,16 @@ app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
try
{
await packApprovals.AcknowledgeAsync(
tenantId,
packId,
request.AckToken,
request.Decision,
request.Comment,
actor,
timeProvider.GetUtcNow(),
context.RequestAborted).ConfigureAwait(false);
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,

View File

@@ -1,6 +1,9 @@
using StellaOps.Notify.Models;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using System.Collections.Concurrent;
using StellaOps.Notifier.Worker.Correlation;
namespace StellaOps.Notifier.WebService.Storage.Compat;
@@ -50,3 +53,117 @@ public sealed class InMemoryOperatorOverrideRepository : INotifyOperatorOverride
private ConcurrentDictionary<string, NotifyOperatorOverride> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyOperatorOverride>());
}
public sealed class PostgresNotifyOperatorOverrideRepository : INotifyOperatorOverrideRepository
{
private readonly IOperatorOverrideRepository _repository;
public PostgresNotifyOperatorOverrideRepository(IOperatorOverrideRepository repository)
{
_repository = repository;
}
public async Task<IReadOnlyList<NotifyOperatorOverride>> ListAsync(string tenantId, bool? activeOnly, DateTimeOffset now, CancellationToken cancellationToken = default)
{
var entities = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var items = entities.Select(MapModel);
if (activeOnly == true)
{
items = items.Where(item => item.IsActiveAt(now));
}
return items.OrderBy(item => item.ExpiresAt).ToList();
}
public async Task<NotifyOperatorOverride?> GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
{
var entity = await _repository.GetByIdAsync(tenantId, overrideId, cancellationToken).ConfigureAwait(false);
return entity is null ? null : MapModel(entity);
}
public async Task<NotifyOperatorOverride> UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(@override);
var entity = MapEntity(@override);
var existing = await _repository.GetByIdAsync(@override.TenantId, @override.OverrideId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await _repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
}
else
{
await _repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
}
return @override;
}
public Task<bool> DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
=> _repository.DeleteAsync(tenantId, overrideId, cancellationToken);
private static NotifyOperatorOverride MapModel(OperatorOverrideEntity entity)
{
return NotifyOperatorOverride.Create(
overrideId: entity.OverrideId,
tenantId: entity.TenantId,
overrideType: MapOverrideType(entity.OverrideType),
expiresAt: entity.ExpiresAt,
channelId: entity.ChannelId,
ruleId: entity.RuleId,
reason: entity.Reason,
createdBy: entity.CreatedBy,
createdAt: entity.CreatedAt);
}
private static OperatorOverrideEntity MapEntity(NotifyOperatorOverride @override)
{
return new OperatorOverrideEntity
{
OverrideId = @override.OverrideId,
TenantId = @override.TenantId,
OverrideType = MapOverrideType(@override.OverrideType).ToString(),
EffectiveFrom = @override.CreatedAt,
ExpiresAt = @override.ExpiresAt,
ChannelId = @override.ChannelId,
RuleId = @override.RuleId,
Reason = @override.Reason,
EventKinds = "[]",
CorrelationKeys = "[]",
MaxUsageCount = null,
UsageCount = 0,
Status = @override.ExpiresAt > DateTimeOffset.UtcNow ? "active" : "expired",
RevokedBy = null,
RevokedAt = null,
RevocationReason = null,
CreatedBy = @override.CreatedBy,
CreatedAt = @override.CreatedAt,
UpdatedAt = @override.CreatedAt
};
}
private static NotifyOverrideType MapOverrideType(string value)
{
var parsed = Enum.TryParse<OverrideType>(value, ignoreCase: true, out var type)
? type
: OverrideType.All;
return parsed switch
{
OverrideType.QuietHours => NotifyOverrideType.BypassQuietHours,
OverrideType.Throttle => NotifyOverrideType.BypassThrottle,
OverrideType.Maintenance => NotifyOverrideType.BypassMaintenance,
_ => NotifyOverrideType.ForceSuppression
};
}
private static OverrideType MapOverrideType(NotifyOverrideType value) => value switch
{
NotifyOverrideType.BypassQuietHours => OverrideType.QuietHours,
NotifyOverrideType.BypassThrottle => OverrideType.Throttle,
NotifyOverrideType.BypassMaintenance => OverrideType.Maintenance,
NotifyOverrideType.ForceSuppression => OverrideType.All,
_ => OverrideType.All
};
}

View File

@@ -1,12 +1,25 @@
using StellaOps.Notify.Models;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using System.Collections.Concurrent;
using System.Text.Json;
namespace StellaOps.Notifier.WebService.Storage.Compat;
public interface INotifyPackApprovalRepository
{
Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default);
Task<bool> AcknowledgeAsync(
string tenantId,
string packId,
string ackToken,
string? decision,
string? comment,
string actor,
DateTimeOffset acknowledgedAt,
CancellationToken cancellationToken = default);
}
public sealed class InMemoryPackApprovalRepository : INotifyPackApprovalRepository
@@ -18,9 +31,107 @@ public sealed class InMemoryPackApprovalRepository : INotifyPackApprovalReposito
_store[(document.TenantId, document.EventId, document.PackId)] = document;
return Task.CompletedTask;
}
public Task<bool> AcknowledgeAsync(
string tenantId,
string packId,
string ackToken,
string? decision,
string? comment,
string actor,
DateTimeOffset acknowledgedAt,
CancellationToken cancellationToken = default)
{
foreach (var entry in _store)
{
if (!string.Equals(entry.Key.TenantId, tenantId, StringComparison.Ordinal) ||
!string.Equals(entry.Key.PackId, packId, StringComparison.Ordinal))
{
continue;
}
_store[entry.Key] = entry.Value with
{
AckToken = ackToken,
AckDecision = decision,
AckComment = comment,
AckActor = actor,
AcknowledgedAt = acknowledgedAt
};
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
public sealed class PackApprovalDocument
public sealed class PostgresPackApprovalRepository : INotifyPackApprovalRepository
{
private readonly IPackApprovalRepository _repository;
public PostgresPackApprovalRepository(IPackApprovalRepository repository)
{
_repository = repository;
}
public async Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(document);
var approval = new PackApprovalEntity
{
TenantId = document.TenantId,
PackId = document.PackId,
EventId = document.EventId,
Kind = document.Kind,
Decision = document.Decision,
Actor = document.Actor,
IssuedAt = document.IssuedAt,
CreatedAt = document.CreatedAt,
PolicyId = document.PolicyId,
PolicyVersion = document.PolicyVersion,
ResumeToken = document.ResumeToken,
Summary = document.Summary,
Labels = SerializeMap(document.Labels),
Metadata = SerializeMap(document.Metadata)
};
await _repository.UpsertAsync(approval, cancellationToken).ConfigureAwait(false);
}
public Task<bool> AcknowledgeAsync(
string tenantId,
string packId,
string ackToken,
string? decision,
string? comment,
string actor,
DateTimeOffset acknowledgedAt,
CancellationToken cancellationToken = default)
=> _repository.AcknowledgeAsync(
tenantId,
packId,
ackToken,
decision,
comment,
actor,
acknowledgedAt,
cancellationToken);
private static string SerializeMap(IDictionary<string, string>? values)
{
if (values is null || values.Count == 0)
{
return "{}";
}
var ordered = new SortedDictionary<string, string>(values, StringComparer.Ordinal);
return JsonSerializer.Serialize(ordered);
}
}
public sealed record PackApprovalDocument
{
public required string TenantId { get; init; }
public required Guid EventId { get; init; }
@@ -36,4 +147,9 @@ public sealed class PackApprovalDocument
public string? Summary { get; init; }
public IDictionary<string, string>? Labels { get; init; }
public IDictionary<string, string>? Metadata { get; init; }
public string? AckToken { get; init; }
public string? AckDecision { get; init; }
public string? AckComment { get; init; }
public string? AckActor { get; init; }
public DateTimeOffset? AcknowledgedAt { get; init; }
}

View File

@@ -1,6 +1,10 @@
using StellaOps.Notify.Models;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.Notifier.WebService.Storage.Compat;
@@ -47,3 +51,116 @@ public sealed class InMemoryThrottleConfigRepository : INotifyThrottleConfigRepo
private ConcurrentDictionary<string, NotifyThrottleConfig> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyThrottleConfig>());
}
public sealed class PostgresNotifyThrottleConfigRepository : INotifyThrottleConfigRepository
{
private readonly IThrottleConfigRepository _repository;
public PostgresNotifyThrottleConfigRepository(IThrottleConfigRepository repository)
{
_repository = repository;
}
public async Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var entities = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
return entities.Select(MapModel).ToList();
}
public async Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
var entity = await _repository.GetByIdAsync(tenantId, configId, cancellationToken).ConfigureAwait(false);
return entity is null ? null : MapModel(entity);
}
public async Task<NotifyThrottleConfig> UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(config);
var entity = MapEntity(config);
var existing = await _repository.GetByIdAsync(config.TenantId, config.ConfigId, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await _repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
}
else
{
await _repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
}
return config;
}
public Task<bool> DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
=> _repository.DeleteAsync(tenantId, configId, cancellationToken);
private static NotifyThrottleConfig MapModel(ThrottleConfigEntity entity)
{
return NotifyThrottleConfig.Create(
configId: entity.ConfigId,
tenantId: entity.TenantId,
name: entity.Name,
defaultWindow: entity.DefaultWindow,
maxNotificationsPerWindow: entity.MaxNotificationsPerWindow,
channelId: entity.ChannelId,
isDefault: entity.IsDefault,
enabled: entity.Enabled,
description: entity.Description,
metadata: DeserializeMetadata(entity.Metadata),
createdBy: entity.CreatedBy,
createdAt: entity.CreatedAt,
updatedBy: entity.UpdatedBy,
updatedAt: entity.UpdatedAt);
}
private static ThrottleConfigEntity MapEntity(NotifyThrottleConfig config)
{
return new ThrottleConfigEntity
{
ConfigId = config.ConfigId,
TenantId = config.TenantId,
Name = config.Name,
DefaultWindow = config.DefaultWindow,
MaxNotificationsPerWindow = config.MaxNotificationsPerWindow,
ChannelId = config.ChannelId,
IsDefault = config.IsDefault,
Enabled = config.Enabled,
Description = config.Description,
Metadata = SerializeMetadata(config.Metadata),
CreatedBy = config.CreatedBy,
CreatedAt = config.CreatedAt,
UpdatedBy = config.UpdatedBy,
UpdatedAt = config.UpdatedAt
};
}
private static string? SerializeMetadata(IReadOnlyDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return null;
}
var ordered = metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
return JsonSerializer.Serialize(ordered);
}
private static ImmutableDictionary<string, string>? DeserializeMetadata(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
var metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
return metadata?.ToImmutableDictionary(StringComparer.Ordinal);
}
catch
{
return null;
}
}
}

View File

@@ -16,6 +16,15 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0395-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Notifier.WebService. |
| AUDIT-0395-A | TODO | Revalidated 2026-01-07 (open findings). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| NOTIFY-SUPPRESS-001 | DONE | 2026-04-16: replaced live in-memory throttle and override admin runtime with PostgreSQL-backed services/adapters. |
| NOTIFY-SUPPRESS-002 | DONE | 2026-04-16: restart-survival proof covers durable throttle and override suppression APIs. |
| NOTIFY-ESC-ONCALL-001 | DONE | 2026-04-16: replaced live in-memory escalation-policy and on-call compat repositories with PostgreSQL-backed adapters. |
| NOTIFY-ESC-ONCALL-002 | DONE | 2026-04-16: restart-survival proof covers durable escalation-policy and on-call APIs. |
| NOTIFY-QUIET-MAINT-001 | DONE | 2026-04-16: replaced live in-memory quiet-hours and maintenance-window compat/runtime services with PostgreSQL-backed adapters. |
| NOTIFY-QUIET-MAINT-002 | DONE | 2026-04-16: restart-survival proof covers durable quiet-hours and maintenance APIs. |
| NOTIFY-SEC-DEAD-001 | DONE | 2026-04-16: replaced live in-memory webhook security, tenant isolation, dead-letter, and retention runtime with PostgreSQL-backed services. |
| NOTIFY-SEC-DEAD-002 | DONE | 2026-04-16: both hosts now wire compatibility and canonical security/dead-letter endpoints to the same durable runtime services. |
| NOTIFY-SEC-DEAD-003 | DONE | 2026-04-16: restart-survival proof passed for webhook security, tenant isolation, dead-letter, and retention runtime. |