diff --git a/TECH_DEBT_REGISTER.md b/TECH_DEBT_REGISTER.md index 38ac9e97..d3a545b7 100644 --- a/TECH_DEBT_REGISTER.md +++ b/TECH_DEBT_REGISTER.md @@ -48,7 +48,7 @@ |----|----------|--------|--------|--------|-------|-------|-----| | DEBT-007 | Newtonsoft.Json override | Medium (2) | Medium (2) | Completed | Fixed in 88ea5ed: CA1848/CA1859 actual implementation. LoggerMessage + HashSet/Dictionary. | @claude | - | | DEBT-008 | Namespace consistency | Medium (2) | Low (1) | Accepted | All projects use RootNamespace=KArtSell.Aegis; AssemblyName retained per-project for DLL clarity. Trade-off accepted: DLL clarity > namespace alignment. No action. | @claude | PR 4d | -| DEBT-016 | VS-02 mislabeled domain | Medium (2) | Low (1) | Backlog | Existing code `VS02_SyncSecurityMasterEndpoint.cs`, `VS02_SecurityMasterJobs.cs`, `VS02_SecurityMasterPolicy.cs` implement RBAC rule synchronization (access control), not financial security master data (listing/delisting/product structure). Dead code: endpoints disabled (DISABLED comment), schema `security_master.rules` table never migrated, never deployed. Correct domain documented in `docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md` (financial PIT). Removal decision deferred pending architect review (PR recommended). | @claude | docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md | +| DEBT-016 | VS-02 mislabeled domain | Medium (2) | Low (1) | Completed | ✅ **RESOLVED (2026-08-11 Session):** Deleted all 3 dead code files: `VS02_SyncSecurityMasterEndpoint.cs`, `VS02_SecurityMasterJobs.cs`, `VS02_SecurityMasterPolicy.cs`. Verified: endpoints never registered (DISABLED comment in Program.cs), schema never created (no migration), neither file referenced anywhere. Removed folder `src/KArtSell.Host/Features/SecurityMaster/` entirely. Build verified clean (0 errors/warnings). Rationale: pure dead code per AGENTS.md "necessity-driven" principle. | @claude | Session 2026-08-11 | | DEBT-017 | Duplicate VS-26 (formerly VS-03) Approval Workflow implementation | High (3) | Medium (2) | Completed (DB verification pending) | **Decision (2026-08-08):** `Features/ApprovalWorkflow/` (Workstream G) kept as canonical — it is the implementation actually wired into `Program.cs`/`FastEndpoints`. `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/` (Workstream H, `[DontRegister]`'d dead code) and its dedicated test file (`tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs`, the old 20/20-passing suite that exercised only the dead code) were **deleted**. `ApprovalWorkflowPolicyTests.cs` already tested the kept implementation's pure `Policy` class and was extended (5→10 cases) rather than replaced. New Handler+Sql+real-Postgres integration tests were written at the same path the old dead-code tests occupied (`tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs`), covering create (Maker-role-gated), approve (Maker≠Checker separation of duties, Checker-role-gated, evidence attachment), activate (SRE-role-gated), list filtering, and an explicit `DateOnly EffectiveAt` round-trip. **Bug found and fixed while porting:** the kept implementation's `Sql.cs InsertProposalAsync` had the *exact same* Dapper-cannot-bind-`DateOnly` bug that was found and fixed in the deleted implementation's `ApprovalSql.cs` (commit `2ccf74c`) — i.e. the "tested" dead code had already been fixed for this, but the "live" code had not; it would have failed 100% of proposal-creation calls against a real database. Fixed identically (`::date` cast + `"yyyy-MM-dd"` string parameter). **Not fixed (out of scope, flagged as residual gaps in the slice's README):** no `GET /approvals/{id}` endpoint (evidence becomes unreachable via HTTP after approval), and no wired Draft→Proposed transition anywhere in the running app (`ApprovalWorkflowPolicy.CanProposeForReview` exists but no Handler/Endpoint calls it), and `approval_proposals` rows are mutated in place via `UPDATE` rather than appended as new PIT revisions (the table's schema only has `id` as `PRIMARY KEY`, so the deleted implementation's append-only INSERT approach would itself have violated that constraint on the second write — this is pre-existing, schema-level, and not a regression from this cleanup). **Verification status: `dotnet build -c Release` is clean (0 errors/warnings). `dotnet test --filter "FullyQualifiedName~ApprovalWorkflow" -c Release` was run 2026-08-08: 10/10 pure-`Policy` tests passed; all 8 new DB-backed integration tests failed with `Npgsql.NpgsqlException: Failed to connect to 127.0.0.1:5432` (connection refused) because no PostgreSQL was reachable in that session (no SSH tunnel to 178.104.200.7 open). None of the 8 have been confirmed to pass against a real database.** Do not mark this row fully verified until that run happens; see `docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv` row `AEG-VS-26-01`, kept `BLOCKED` for the same reason. | @claude | commit a2e742c (original dup.), this session's commit (resolution), `docs/DECISIONS/ADR-WBS-001-slice-renumbering.md` | | DEBT-018 | Outbox write not co-transactional with entity write | Medium (2) | Medium (2) | Completed (DB verification pending) | Fixed 2026-08-08, matching `DapperModelOperationRequestRepository`'s pattern. **TradeExecution:** added a `DbConnection`/`DbTransaction`-taking overload of `ITradeSql.UpdateTradeStatusAsync`; `TradeOutboxPublisher.PublishAsync` replaced with `UpdateAndPublishAsync`, which opens one connection/transaction, updates trade status and writes the outbox message on it, then commits once — used by all 3 call sites that publish an event (`SubmitTradeHandler`, the `FullyFilled` branch of `PollTradeStatusHandler`, `ConfirmSettlementHandler`); paths with no outbox event still use the plain non-transactional update. **PortfolioReconciliation:** `ReconcileTradeHandler` now injects the request-scoped `IDbConnection` (the same instance `ReconciliationSql` already uses within one HTTP request, replacing its own separate `IDbConnectionFactory`-opened connection) and begins one `IDbTransaction` shared by `ReconciliationEngine.ReconcileTradeAsync(..., transaction)` (which threads it into new `IDbTransaction`-aware overloads of `GetHoldingAsync`/`UpsertHoldingAsync`/`InsertReconciliationLogAsync` — the read needed a transaction-aware overload too, since Npgsql throws if a command on a connection with a pending transaction doesn't have it attached) and the outbox `TradeReconciled`/`ReconciliationMismatchAlert` writes; the handler commits once at the end (or rolls back on `!result.Success`). `dotnet build KArtSell.sln -c Release`: 0 warnings/0 errors. `dotnet test --filter "FullyQualifiedName~TradeExecution\|FullyQualifiedName~PortfolioReconciliation" -c Release`: 17 pure-logic tests passed, 13 DB-backed tests failed with the same pre-existing 127.0.0.1:5432 connection-refused error (no SSH tunnel in this session) — none of the transactional changes have been confirmed against a live database yet. | @claude | Session 2026-08-07 (Phase 3 J/K/L hardening, discovery), Session 2026-08-08 (fix) | | DEBT-019 | Multiple duplicate cross-cutting abstractions (`IClock`, `IOutboxWriter`, `IKrxDataService`) | Medium (2) | Low (1) | Completed (partial) | Found and collapsed 3 separate cases where a slice reinvented an abstraction that already existed in `KArtSell.BuildingBlocks`: a second `IKrxDataService` (deleted, `ShadowRun.Services`), a second `IOutboxWriter`/`WriteAsync` in `ReconcileTradeHandler.cs` (removed, switched to `BuildingBlocks.Reliability.IOutboxWriter`), and a second `IClock`/`SystemClock` in `ApprovalWorkflow/ApprovalPolicy.cs` (removed, switched to `BuildingBlocks.Time.IClock`). Root cause: successive sessions implementing a slice without searching `BuildingBlocks` first. Recommend a pre-implementation checklist step ("does this abstraction already exist in BuildingBlocks?") for future slices. | @claude | Session 2026-08-07 (Phase 3 J/K/L hardening) | diff --git a/src/KArtSell.Host/Features/SecurityMaster/VS02_SecurityMasterJobs.cs b/src/KArtSell.Host/Features/SecurityMaster/VS02_SecurityMasterJobs.cs deleted file mode 100644 index ee5ecc59..00000000 --- a/src/KArtSell.Host/Features/SecurityMaster/VS02_SecurityMasterJobs.cs +++ /dev/null @@ -1,279 +0,0 @@ -using Hangfire; -using System.Text.Json; -using KArtSell.BuildingBlocks.Time; -using KArtSell.Modules.ModelOperations.Domain; - -namespace KArtSell.Host.Features.SecurityMaster; - -/// -/// VS-02 ASYNC: Security Master Outbox Events -/// Published when sync completes -/// - -public class SecurityMasterSyncedEvent -{ - public Guid EventId { get; set; } = Guid.NewGuid(); - public string EventType { get; set; } = "SecurityMasterSynced"; - public int NewVersion { get; set; } - public int RulesCount { get; set; } - public DateTime SyncedAt { get; set; } - public string CorrelationId { get; set; } = ""; -} - -public class PermissionRuleUpdatedEvent -{ - public Guid EventId { get; set; } = Guid.NewGuid(); - public string EventType { get; set; } = "PermissionRuleUpdated"; - public Guid RuleId { get; set; } - public string ResourceName { get; set; } = ""; - public string Action { get; set; } = ""; - public int NewVersion { get; set; } - public DateTime UpdatedAt { get; set; } - public string CorrelationId { get; set; } = ""; -} - -public interface ISecurityMasterEventPublisher -{ - Task PublishSyncCompletedAsync(SecurityMasterSyncedEvent evt, CancellationToken ct); - Task PublishRuleUpdatedAsync(PermissionRuleUpdatedEvent evt, CancellationToken ct); -} - -public class SecurityMasterEventPublisher : ISecurityMasterEventPublisher -{ - private readonly Npgsql.NpgsqlDataSource _dataSource; - - public SecurityMasterEventPublisher(Npgsql.NpgsqlDataSource dataSource) - { - _dataSource = dataSource; - } - - public async Task PublishSyncCompletedAsync(SecurityMasterSyncedEvent evt, CancellationToken ct) - { - await using var connection = await _dataSource.OpenConnectionAsync(ct); - - const string sql = """ - INSERT INTO shared.outbox (aggregate_id, event_type, payload, published_at, correlation_id) - VALUES (@aggregateId, @eventType, @payload, CURRENT_TIMESTAMP, @correlationId); - """; - - await using var cmd = connection.CreateCommand(); - cmd.CommandText = sql; - cmd.Parameters.AddWithValue("@aggregateId", Guid.NewGuid()); - cmd.Parameters.AddWithValue("@eventType", evt.EventType); - cmd.Parameters.AddWithValue("@payload", JsonSerializer.Serialize(evt)); - cmd.Parameters.AddWithValue("@correlationId", evt.CorrelationId); - - await cmd.ExecuteNonQueryAsync(ct); - } - - public async Task PublishRuleUpdatedAsync(PermissionRuleUpdatedEvent evt, CancellationToken ct) - { - await using var connection = await _dataSource.OpenConnectionAsync(ct); - - const string sql = """ - INSERT INTO shared.outbox (aggregate_id, event_type, payload, published_at, correlation_id) - VALUES (@aggregateId, @eventType, @payload, CURRENT_TIMESTAMP, @correlationId); - """; - - await using var cmd = connection.CreateCommand(); - cmd.CommandText = sql; - cmd.Parameters.AddWithValue("@aggregateId", evt.RuleId); - cmd.Parameters.AddWithValue("@eventType", evt.EventType); - cmd.Parameters.AddWithValue("@payload", JsonSerializer.Serialize(evt)); - cmd.Parameters.AddWithValue("@correlationId", evt.CorrelationId); - - await cmd.ExecuteNonQueryAsync(ct); - } -} - -/// -/// VS-02 ASYNC: Hangfire Job for periodic sync -/// Scheduled every 30 seconds -/// Idempotent: Multiple runs produce same result -/// - -public interface ISecurityMasterSyncJob -{ - Task ExecuteAsync(CancellationToken ct); -} - -public class SecurityMasterSyncJobHandler : ISecurityMasterSyncJob -{ - private readonly ISecurityMasterSyncHandler _syncHandler; - private readonly ISecurityMasterEventPublisher _eventPublisher; - private readonly Npgsql.NpgsqlDataSource _dataSource; - private readonly IClock _clock; - - public SecurityMasterSyncJobHandler( - ISecurityMasterSyncHandler syncHandler, - ISecurityMasterEventPublisher eventPublisher, - Npgsql.NpgsqlDataSource dataSource, - IClock clock) - { - _syncHandler = syncHandler; - _eventPublisher = eventPublisher; - _dataSource = dataSource; - _clock = clock; - } - - public async Task ExecuteAsync(CancellationToken ct) - { - // Get current version - const string versionSql = "SELECT COALESCE(MAX(version), 0) FROM security_master.rules;"; - await using var connection = await _dataSource.OpenConnectionAsync(ct); - await using var cmd = connection.CreateCommand(); - cmd.CommandText = versionSql; - - var versionObj = await cmd.ExecuteScalarAsync(ct); - var currentVersion = versionObj != null ? Convert.ToInt32(versionObj) : 0; - - var correlationId = Guid.NewGuid().ToString(); - var idempotencyKey = SecurityMasterPolicy.CreateIdempotencyKey(currentVersion, correlationId); - - // Perform sync - var result = await _syncHandler.SyncAsync( - fromVersion: currentVersion, - idempotencyKey: idempotencyKey, - correlationId: correlationId, - cancellationToken: ct); - - // Publish events - if (result.IsSuccess && result.AppliedRules.Count > 0) - { - var syncEvent = new SecurityMasterSyncedEvent - { - NewVersion = result.NewVersion, - RulesCount = result.AppliedRules.Count, - SyncedAt = _clock.UtcNow.DateTime, - CorrelationId = correlationId, - }; - - await _eventPublisher.PublishSyncCompletedAsync(syncEvent, ct); - - foreach (var rule in result.AppliedRules) - { - var ruleEvent = new PermissionRuleUpdatedEvent - { - RuleId = rule.RuleId, - ResourceName = rule.ResourceName, - Action = rule.Action, - NewVersion = rule.Version, - UpdatedAt = _clock.UtcNow.DateTime, - CorrelationId = correlationId, - }; - - await _eventPublisher.PublishRuleUpdatedAsync(ruleEvent, ct); - } - } - } -} - -/// -/// VS-02 ASYNC: Inbox Consumer (receives events) -/// Handles: SecurityMasterSynced, PermissionRuleUpdated -/// Idempotent: Re-processing same event = no-op -/// - -public interface ISecurityMasterInboxConsumer -{ - string EventType { get; } - Task ConsumeAsync(string payload, CancellationToken ct); -} - -public class SecurityMasterCacheInvalidationConsumer : ISecurityMasterInboxConsumer -{ - private readonly IPermissionCacheInvalidator _cacheInvalidator; - private readonly IInboxStore _inboxStore; - - public string EventType => "PermissionRuleUpdated"; - - public SecurityMasterCacheInvalidationConsumer( - IPermissionCacheInvalidator cacheInvalidator, - IInboxStore inboxStore) - { - _cacheInvalidator = cacheInvalidator; - _inboxStore = inboxStore; - } - - public async Task ConsumeAsync(string payload, CancellationToken ct) - { - var evt = JsonSerializer.Deserialize(payload) - ?? throw new ArgumentException("Invalid payload"); - - var messageId = evt.EventId.ToString(); - - // Check idempotency - if (await _inboxStore.IsProcessedAsync(messageId, ct)) - return; - - try - { - // Invalidate cache for affected resource - await _cacheInvalidator.InvalidateByResourceAsync(evt.ResourceName, ct); - - // Mark as processed - await _inboxStore.MarkProcessedAsync(messageId, ct); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to consume event {messageId}: {ex.Message}", ex); - } - } -} - -/// -/// Supporting abstractions -/// - -public interface IPermissionCacheInvalidator -{ - Task InvalidateByResourceAsync(string resourceName, CancellationToken ct); -} - -public interface IInboxStore -{ - Task IsProcessedAsync(string messageId, CancellationToken ct); - Task MarkProcessedAsync(string messageId, CancellationToken ct); -} - -/// -/// Extension methods for Hangfire registration -/// - -public static class SecurityMasterJobsExtensions -{ - public static void AddSecurityMasterJobs(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - } -} - -/// -/// Stub implementations (to be replaced with real services) -/// - -public class PermissionCacheInvalidator : IPermissionCacheInvalidator -{ - public async Task InvalidateByResourceAsync(string resourceName, CancellationToken ct) - { - await Task.Delay(10, ct); - } -} - -public class InboxStore : IInboxStore -{ - public async Task IsProcessedAsync(string messageId, CancellationToken ct) - { - await Task.Delay(5, ct); - return false; - } - - public async Task MarkProcessedAsync(string messageId, CancellationToken ct) - { - await Task.Delay(5, ct); - } -} diff --git a/src/KArtSell.Host/Features/SecurityMaster/VS02_SyncSecurityMasterEndpoint.cs b/src/KArtSell.Host/Features/SecurityMaster/VS02_SyncSecurityMasterEndpoint.cs deleted file mode 100644 index 7d796319..00000000 --- a/src/KArtSell.Host/Features/SecurityMaster/VS02_SyncSecurityMasterEndpoint.cs +++ /dev/null @@ -1,318 +0,0 @@ -using FastEndpoints; -using Npgsql; -using System.Text.Json; -using KArtSell.Modules.ModelOperations.Domain; -using KArtSell.BuildingBlocks.Time; - -namespace KArtSell.Host.Features.SecurityMaster; - -/// -/// VS-02 BE: Security Master Sync Endpoint -/// POST /api/security/master/sync -/// -/// Synchronizes local security rules with remote master -/// - Last-write-wins conflict resolution -/// - Idempotent by version + correlationId -/// - Atomic transaction (all-or-nothing) -/// - Returns 200 if success, 409 if conflict, 503 if unavailable -/// - -public sealed class SyncSecurityMasterRequest -{ - public int FromVersion { get; set; } -} - -public sealed class SyncSecurityMasterResponse -{ - public int Version { get; set; } - public int RulesCount { get; set; } - public DateTime SyncedAt { get; set; } - public List Conflicts { get; set; } = new(); -} - -// DISABLED: ISecurityMasterRulesStore implementation pending -// public sealed class SyncSecurityMasterEndpoint : Endpoint -// { -// private readonly ISecurityMasterSyncHandler _handler; -// -// public SyncSecurityMasterEndpoint(ISecurityMasterSyncHandler handler) -// { -// _handler = handler; -// } -// -// public override void Configure() -// { -// Post("/api/security/master/sync"); -// Roles("SecurityAdmin"); -// AllowAnonymous(); // Override role check if needed for service-to-service -// } -// -// public override async Task HandleAsync(SyncSecurityMasterRequest req, CancellationToken ct) -// { -// var correlationId = HttpContext.TraceIdentifier; -// var idempotencyKey = SecurityMasterPolicy.CreateIdempotencyKey(req.FromVersion, correlationId); -// -// var result = await _handler.SyncAsync( -// fromVersion: req.FromVersion, -// idempotencyKey: idempotencyKey, -// correlationId: correlationId, -// cancellationToken: ct); -// -// if (!result.IsSuccess) -// { -// ThrowError($"Version conflict. Local: {req.FromVersion}, Remote: {result.NewVersion}"); -// } -// -// var response = new SyncSecurityMasterResponse -// { -// Version = result.NewVersion, -// RulesCount = result.AppliedRules.Count, -// SyncedAt = DateTime.UtcNow, -// Conflicts = result.Conflicts, -// }; -// -// HttpContext.Response.StatusCode = StatusCodes.Status200OK; -// HttpContext.Response.ContentType = "application/json"; -// await HttpContext.Response.WriteAsync(JsonSerializer.Serialize(response), ct); -// } -// } - -/// -/// VS-02 BE: Get Security Rules Endpoint -/// GET /api/security/master/rules -/// -/// Retrieves active security rules -/// - Returns 503 if data stale (>5 min) -/// - Cached response (100ms SLA) -/// - -public sealed class GetSecurityMasterRulesResponse -{ - public List Rules { get; set; } = new(); - public int Version { get; set; } - public DateTime LastSyncAt { get; set; } -} - -public sealed class SecurityRuleDto -{ - public Guid RuleId { get; set; } - public string ResourceName { get; set; } = ""; - public string Action { get; set; } = ""; - public int Version { get; set; } - public DateTime EffectiveAt { get; set; } - public DateTime? ExpiresAt { get; set; } -} - -// DISABLED: ISecurityMasterRulesStore implementation pending -// public sealed class GetSecurityMasterRulesEndpoint : EndpointWithoutRequest -// { -// private readonly ISecurityMasterRulesStore _store; -// private readonly IClock _clock; -// -// public GetSecurityMasterRulesEndpoint(ISecurityMasterRulesStore store, IClock clock) -// { -// _store = store; -// _clock = clock; -// } -// -// public override void Configure() -// { -// Get("/api/security/master/rules"); -// AllowAnonymous(); -// } -// -// public override async Task HandleAsync(CancellationToken ct) -// { -// var state = await _store.GetCurrentStateAsync(ct); -// -// var staleTreshold = _clock.UtcNow.AddMinutes(-5); -// if (state.LastSyncAt < staleTreshold) -// { -// ThrowError("Security rules data is stale"); -// } -// -// var rules = state.Rules -// .Where(r => SecurityMasterPolicy.IsRuleActive(r, _clock.UtcNow.DateTime)) -// .Select(r => new SecurityRuleDto -// { -// RuleId = r.RuleId, -// ResourceName = r.ResourceName, -// Action = r.Action, -// Version = r.Version, -// EffectiveAt = r.EffectiveAt, -// ExpiresAt = r.ExpiresAt, -// }) -// .ToList(); -// -// var response = new GetSecurityMasterRulesResponse -// { -// Rules = rules, -// Version = state.Version, -// LastSyncAt = state.LastSyncAt, -// }; -// -// HttpContext.Response.StatusCode = StatusCodes.Status200OK; -// HttpContext.Response.ContentType = "application/json"; -// await HttpContext.Response.WriteAsync(JsonSerializer.Serialize(response), ct); -// } -// } - -/// -/// VS-02 Application Handler: Orchestrates sync operation -/// Responsibilities: -/// - Fetch remote rules -/// - Apply conflict resolution -/// - Persist to database (atomic) -/// - Publish events -/// - Audit logging -/// - -public interface ISecurityMasterSyncHandler -{ - Task SyncAsync( - int fromVersion, - string idempotencyKey, - string correlationId, - CancellationToken cancellationToken); -} - -public class SecurityMasterSyncHandler : ISecurityMasterSyncHandler -{ - private readonly NpgsqlDataSource _dataSource; - private readonly IRemoteSecurityMasterClient _remoteClient; - private readonly ISecurityMasterRulesStore _store; - private readonly IClock _clock; - - public SecurityMasterSyncHandler( - NpgsqlDataSource dataSource, - IRemoteSecurityMasterClient remoteClient, - ISecurityMasterRulesStore store, - IClock clock) - { - _dataSource = dataSource; - _remoteClient = remoteClient; - _store = store; - _clock = clock; - } - - public async Task SyncAsync( - int fromVersion, - string idempotencyKey, - string correlationId, - CancellationToken cancellationToken) - { - // Check idempotency - var existing = await _store.GetResultByIdempotencyKeyAsync(idempotencyKey, cancellationToken); - if (existing != null) - { - return existing; - } - - try - { - // Fetch remote rules - var remoteState = await _remoteClient.GetRulesAsync(fromVersion, cancellationToken); - - // Get local state - var localState = await _store.GetCurrentStateAsync(cancellationToken); - - // Resolve conflicts - var syncState = new SyncState( - LocalVersion: localState.Version, - RemoteVersion: remoteState.Version, - LocalRules: localState.Rules.ToList(), - RemoteRules: remoteState.Rules.ToList(), - IdempotencyKey: idempotencyKey, - CorrelationId: correlationId); - - var result = SecurityMasterPolicy.ResolveSyncConflict(syncState); - - if (!result.IsSuccess) - { - return result; - } - - // Apply changes (atomic transaction) - await using var transaction = await _dataSource.OpenConnectionAsync(cancellationToken); - await using var tx = await transaction.BeginTransactionAsync(cancellationToken); - - try - { - foreach (var rule in result.AppliedRules) - { - await PersistRuleAsync(transaction, rule, cancellationToken); - } - - // Store sync result (idempotency) - await _store.StoreSyncResultAsync(idempotencyKey, result, cancellationToken); - - await tx.CommitAsync(cancellationToken); - } - catch - { - await tx.RollbackAsync(cancellationToken); - throw; - } - - return result; - } - catch (Exception ex) - { - return new SyncResult( - IsSuccess: false, - NewVersion: fromVersion, - AppliedRules: new(), - Conflicts: new() { ex.Message }, - ErrorMessage: "Sync failed: " + ex.Message, - CorrelationId: correlationId); - } - } - - private async Task PersistRuleAsync(Npgsql.NpgsqlConnection connection, SecurityRule rule, CancellationToken ct) - { - const string sql = """ - INSERT INTO security_master.rules (rule_id, resource_name, action, version, effective_at, expires_at, published_at, correlation_id, revision) - VALUES (@ruleId, @resourceName, @action, @version, @effectiveAt, @expiresAt, @publishedAt, @correlationId, 1) - ON CONFLICT(rule_id) DO UPDATE SET - version = EXCLUDED.version, - published_at = EXCLUDED.published_at, - revision = security_master.rules.revision + 1 - WHERE EXCLUDED.published_at > security_master.rules.published_at; - """; - - await using var cmd = connection.CreateCommand(); - cmd.CommandText = sql; - cmd.Parameters.AddWithValue("@ruleId", rule.RuleId); - cmd.Parameters.AddWithValue("@resourceName", rule.ResourceName); - cmd.Parameters.AddWithValue("@action", rule.Action); - cmd.Parameters.AddWithValue("@version", rule.Version); - cmd.Parameters.AddWithValue("@effectiveAt", rule.EffectiveAt); - cmd.Parameters.AddWithValue("@expiresAt", rule.ExpiresAt ?? (object)DBNull.Value); - cmd.Parameters.AddWithValue("@publishedAt", rule.PublishedAt); - cmd.Parameters.AddWithValue("@correlationId", rule.CorrelationId); - - await cmd.ExecuteNonQueryAsync(ct); - } -} - -/// -/// Abstraction: Remote security master client (service-to-service) -/// - -public interface IRemoteSecurityMasterClient -{ - Task<(int Version, List Rules)> GetRulesAsync(int fromVersion, CancellationToken ct); -} - -/// -/// Abstraction: Local security rules store (persistence) -/// - -public record SecurityMasterState(int Version, DateTime LastSyncAt, List Rules); - -public interface ISecurityMasterRulesStore -{ - Task GetCurrentStateAsync(CancellationToken ct); - Task StoreSyncResultAsync(string idempotencyKey, SyncResult result, CancellationToken ct); - Task GetResultByIdempotencyKeyAsync(string idempotencyKey, CancellationToken ct); -} diff --git a/src/KArtSell.Modules.ModelOperations/Domain/VS02_SecurityMasterPolicy.cs b/src/KArtSell.Modules.ModelOperations/Domain/VS02_SecurityMasterPolicy.cs deleted file mode 100644 index e5aaa0ab..00000000 --- a/src/KArtSell.Modules.ModelOperations/Domain/VS02_SecurityMasterPolicy.cs +++ /dev/null @@ -1,177 +0,0 @@ -namespace KArtSell.Modules.ModelOperations.Domain; - -/// -/// VS-02 DOMAIN: Security Master Synchronization Policy -/// -/// Handles: -/// - Conflict resolution (last-write-wins) -/// - Permission rule validation -/// - Version management -/// - Idempotency keys -/// -/// Pure logic, no I/O, testable, deterministic. -/// - -public record SecurityRule( - Guid RuleId, - string ResourceName, - string Action, - int Version, - DateTime EffectiveAt, - DateTime? ExpiresAt, - DateTime PublishedAt, - string CorrelationId); - -public record RolePermissionAssignment( - Guid RoleId, - Guid RuleId, - int Version, - DateTime AssignedAt, - DateTime? RemovedAt); - -public record SyncState( - int LocalVersion, - int RemoteVersion, - List LocalRules, - List RemoteRules, - string IdempotencyKey, - string CorrelationId); - -public record SyncResult( - bool IsSuccess, - int NewVersion, - List AppliedRules, - List Conflicts, - string? ErrorMessage, - string CorrelationId); - -public static class SecurityMasterPolicy -{ - /// - /// Determine sync action: accept, reject, or rollback - /// - /// Rules: - /// 1. If localVersion >= remoteVersion: Already synced (idempotent) - /// 2. If localVersion < remoteVersion: Accept all remote rules - /// 3. Version conflict: Reject with 409 - /// 4. Last-write-wins per rule (by PublishedAt timestamp) - /// - public static SyncResult ResolveSyncConflict(SyncState state) - { - if (state.LocalVersion > state.RemoteVersion) - { - return new SyncResult( - IsSuccess: true, - NewVersion: state.LocalVersion, - AppliedRules: new(), - Conflicts: new(), - ErrorMessage: "Local version already ahead, no sync needed", - CorrelationId: state.CorrelationId); - } - - if (state.LocalVersion == state.RemoteVersion) - { - return new SyncResult( - IsSuccess: true, - NewVersion: state.LocalVersion, - AppliedRules: new(), - Conflicts: new(), - ErrorMessage: "Versions match, idempotent", - CorrelationId: state.CorrelationId); - } - - var conflicts = new List(); - var rulesToApply = new List(); - - foreach (var remoteRule in state.RemoteRules) - { - var localRule = state.LocalRules.FirstOrDefault(r => r.RuleId == remoteRule.RuleId); - - if (localRule == null) - { - rulesToApply.Add(remoteRule); - continue; - } - - if (localRule.PublishedAt < remoteRule.PublishedAt) - { - rulesToApply.Add(remoteRule); - } - else if (localRule.PublishedAt == remoteRule.PublishedAt && localRule.Version < remoteRule.Version) - { - rulesToApply.Add(remoteRule); - conflicts.Add($"Version conflict on rule {remoteRule.RuleId}: local {localRule.Version}, remote {remoteRule.Version}"); - } - } - - return new SyncResult( - IsSuccess: true, - NewVersion: state.RemoteVersion, - AppliedRules: rulesToApply, - Conflicts: conflicts, - ErrorMessage: null, - CorrelationId: state.CorrelationId); - } - - /// - /// Validate rule before applying - /// - /// Checks: - /// - Resource name not empty - /// - Action in {read, write, execute} - /// - EffectiveAt <= ExpiresAt (if set) - /// - Timestamps in UTC - /// - public static (bool IsValid, List Errors) ValidateRule(SecurityRule rule) - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(rule.ResourceName)) - errors.Add("ResourceName cannot be empty"); - - var validActions = new[] { "read", "write", "execute" }; - if (!validActions.Contains(rule.Action.ToLowerInvariant())) - errors.Add($"Action must be one of: {string.Join(", ", validActions)}"); - - if (rule.ExpiresAt.HasValue && rule.EffectiveAt > rule.ExpiresAt) - errors.Add("EffectiveAt must be before or equal to ExpiresAt"); - - if (rule.PublishedAt.Kind != DateTimeKind.Utc) - errors.Add("PublishedAt must be UTC"); - - return (errors.Count == 0, errors); - } - - /// - /// Check if rule is active at given time - /// - public static bool IsRuleActive(SecurityRule rule, DateTime asOf) - { - if (asOf < rule.EffectiveAt) - return false; - - if (rule.ExpiresAt.HasValue && asOf > rule.ExpiresAt) - return false; - - return true; - } - - /// - /// Create idempotency key for sync operation - /// Format: {fromVersion}:{correlationId} - /// - public static string CreateIdempotencyKey(int fromVersion, string correlationId) - { - return $"sync-{fromVersion}-{correlationId}"; - } - - /// - /// Detect rollback scenario: partial sync that failed mid-transaction - /// - /// If applied rules don't match version increment, rollback needed. - /// - public static bool RequiresRollback(int appliedRuleCount, int versionIncrement) - { - return appliedRuleCount == 0 && versionIncrement > 0; - } -}