diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs index dd65cba..c7060c1 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs @@ -1,4 +1,5 @@ using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; using ModularityKit.Mutator.Governance.Runtime.Lifecycle; using ModularityKit.Mutator.Governance.Tests.TestSupport; @@ -9,7 +10,7 @@ namespace ModularityKit.Mutator.Governance.Tests.Lifecycle; public sealed class MutationRequestLifecycleAtomicityTests { [Fact] - public async Task Stale_snapshot_transition_can_succeed_after_a_prior_lifecycle_update() + public async Task Stale_snapshot_transition_is_rejected_after_a_prior_lifecycle_update() { var request = MutationRequestTestFactory.CreatePendingRequest(); var store = new StaleSnapshotMutationRequestStore(request); @@ -19,15 +20,17 @@ public async Task Stale_snapshot_transition_can_succeed_after_a_prior_lifecycle_ request.RequestId, MutationContext.User("approver", "Approver", "Approve request")); - var canceled = await manager.Cancel( - request.RequestId, - MutationContext.User("operator", "Operator", "Cancel request")); + var exception = await Assert.ThrowsAsync(() => + manager.Cancel( + request.RequestId, + MutationContext.User("operator", "Operator", "Cancel request"))); - Assert.Equal(2, store.StoreCount); + Assert.Equal(1, store.StoreCount); Assert.All(store.GetSnapshots, snapshot => Assert.Equal(MutationRequestStatus.Pending, snapshot.Status)); Assert.Equal(MutationRequestStatus.Approved, approved.Status); - Assert.Equal(MutationRequestStatus.Canceled, canceled.Status); - Assert.NotEqual(approved.Status, canceled.Status); - Assert.Contains(store.Current.Status, new[] { MutationRequestStatus.Approved, MutationRequestStatus.Canceled }); + Assert.Equal(request.RequestId, exception.RequestId); + Assert.Equal(0, exception.ExpectedRevision); + Assert.Equal(MutationRequestStatus.Approved, store.Current.Status); + Assert.Equal(1, store.Current.Revision); } } diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs index a3dbdd2..e5d1e4b 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs @@ -1,4 +1,5 @@ using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; using ModularityKit.Mutator.Governance.Abstractions.Requests; using ModularityKit.Mutator.Governance.Runtime.Storage; @@ -10,32 +11,62 @@ namespace ModularityKit.Mutator.Governance.Tests.Lifecycle; public sealed class MutationRequestStoreContractTests { [Fact] - public async Task Store_contract_allows_blind_overwrite_without_expected_revision_or_status() + public async Task Create_contract_rejects_duplicate_request_ids() { var store = new InMemoryMutationRequestStore(); var request = MutationRequestTestFactory.CreatePendingRequest(); - await store.Store(request); + var created = await store.Create(request); - var overwritten = request with + var exception = await Assert.ThrowsAsync(() => + store.Create(created)); + + Assert.Equal(request.RequestId, exception.RequestId); + } + + [Fact] + public async Task TryStore_rejects_stale_revision_and_preserves_current_state() + { + var store = new InMemoryMutationRequestStore(); + var request = MutationRequestTestFactory.CreatePendingRequest(); + var created = await store.Create(request); + + var firstUpdate = created with + { + Status = MutationRequestStatus.Approved, + PendingReason = null, + Decisions = + [ + .. created.Decisions, + MutationRequestDecision.Create( + MutationRequestDecisionType.Approved, + MutationContext.User("approver", "Approver", "Approve request")) + ] + }; + + var persisted = await store.TryStore(firstUpdate, created.Revision); + Assert.NotNull(persisted); + + var staleUpdate = created with { Status = MutationRequestStatus.Canceled, PendingReason = null, Decisions = [ - .. request.Decisions, + .. created.Decisions, MutationRequestDecision.Create( MutationRequestDecisionType.Canceled, - MutationContext.User("operator", "Operator", "Canceled without guard")) + MutationContext.User("operator", "Operator", "Cancel request")) ] }; - await store.Store(overwritten); + var rejected = await store.TryStore(staleUpdate, created.Revision); var loaded = await store.Get(request.RequestId); + Assert.Null(rejected); Assert.NotNull(loaded); - Assert.Equal(MutationRequestStatus.Canceled, loaded.Status); - Assert.Equal(overwritten.Decisions.Count, loaded.Decisions.Count); + Assert.Equal(MutationRequestStatus.Approved, loaded.Status); + Assert.Equal(1, loaded.Revision); } } diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs index 0c8197c..db73a77 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs @@ -17,7 +17,7 @@ public async Task Resolve_does_not_persist_decision_history_unless_caller_saves_ var resolver = new MutationRequestVersionResolver(); var request = MutationRequestTestFactory.CreateApprovedSecurityRequest("v10"); - await store.Store(request); + await store.Create(request); var resolution = resolver.Resolve( request, diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs index 35185a9..2e9d559 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs @@ -26,17 +26,40 @@ public MutationRequest Current public IReadOnlyList GetSnapshots => _getSnapshots; - public Task Store( + public Task Create( MutationRequest request, CancellationToken cancellationToken = default) { lock (_gate) { StoreCount++; - _current = request; + _current = request with + { + Revision = 0 + }; } - return Task.CompletedTask; + return Task.FromResult(_current); + } + + public Task TryStore( + MutationRequest request, + long expectedRevision, + CancellationToken cancellationToken = default) + { + lock (_gate) + { + if (_current.Revision != expectedRevision) + return Task.FromResult(null); + + StoreCount++; + _current = request with + { + Revision = expectedRevision + 1 + }; + + return Task.FromResult(_current); + } } public Task Get( diff --git a/src/Governance/Abstractions/Exceptions/MutationRequestAlreadyExistsException.cs b/src/Governance/Abstractions/Exceptions/MutationRequestAlreadyExistsException.cs new file mode 100644 index 0000000..4ebeebc --- /dev/null +++ b/src/Governance/Abstractions/Exceptions/MutationRequestAlreadyExistsException.cs @@ -0,0 +1,15 @@ +using ModularityKit.Mutator.Abstractions.Exceptions; + +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions; + +/// +/// Thrown when governance storage is asked to create a request that already exists. +/// +public sealed class MutationRequestAlreadyExistsException(string requestId) + : MutationException($"Mutation request '{requestId}' already exists.") +{ + /// + /// Stable identifier of the duplicate request. + /// + public string RequestId { get; } = requestId; +} diff --git a/src/Governance/Abstractions/Exceptions/MutationRequestConcurrencyException.cs b/src/Governance/Abstractions/Exceptions/MutationRequestConcurrencyException.cs new file mode 100644 index 0000000..90752f8 --- /dev/null +++ b/src/Governance/Abstractions/Exceptions/MutationRequestConcurrencyException.cs @@ -0,0 +1,23 @@ +using ModularityKit.Mutator.Abstractions.Exceptions; + +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions; + +/// +/// Thrown when a governance request transition loses an optimistic concurrency race. +/// +public sealed class MutationRequestConcurrencyException( + string requestId, + long expectedRevision) + : MutationException( + $"Mutation request '{requestId}' could not be updated because revision '{expectedRevision}' is stale.") +{ + /// + /// Stable identifier of the request that lost the concurrency race. + /// + public string RequestId { get; } = requestId; + + /// + /// Revision the runtime expected to update. + /// + public long ExpectedRevision { get; } = expectedRevision; +} diff --git a/src/Governance/Abstractions/Requests/MutationRequest.cs b/src/Governance/Abstractions/Requests/MutationRequest.cs index ab8ac40..9a88746 100644 --- a/src/Governance/Abstractions/Requests/MutationRequest.cs +++ b/src/Governance/Abstractions/Requests/MutationRequest.cs @@ -60,6 +60,11 @@ public sealed record MutationRequest /// public IReadOnlyList Decisions { get; init; } = []; + /// + /// Optimistic concurrency revision for the governed request. + /// + public long Revision { get; init; } + /// /// Expected version or concurrency token for the target state. /// diff --git a/src/Governance/Abstractions/Storage/IMutationRequestStore.cs b/src/Governance/Abstractions/Storage/IMutationRequestStore.cs index 1d4437d..dd16210 100644 --- a/src/Governance/Abstractions/Storage/IMutationRequestStore.cs +++ b/src/Governance/Abstractions/Storage/IMutationRequestStore.cs @@ -9,12 +9,21 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Storage; public interface IMutationRequestStore { /// - /// Stores or updates a mutation request. + /// Creates a new governed mutation request in persistence. /// - Task Store( + Task Create( MutationRequest request, CancellationToken cancellationToken = default); + /// + /// Stores a request only when the persisted revision matches the expected revision. + /// Returns the persisted request with incremented revision on success, or null on conflict. + /// + Task TryStore( + MutationRequest request, + long expectedRevision, + CancellationToken cancellationToken = default); + /// /// Retrieves a single mutation request by its stable identifier. /// diff --git a/src/Governance/README.md b/src/Governance/README.md index 6bf886a..3cd9dde 100644 --- a/src/Governance/README.md +++ b/src/Governance/README.md @@ -38,6 +38,8 @@ Key types: - `MutationRequestVersionResolution` - `MutationRequestVersionResolutionOutcome` - `VersionedRequestResolutionStrategy` +- `MutationRequestAlreadyExistsException` +- `MutationRequestConcurrencyException` - `MutationRequestNotFoundException` - `InvalidMutationRequestTransitionException` diff --git a/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleManager.cs b/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleManager.cs index e990ece..7644727 100644 --- a/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleManager.cs +++ b/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleManager.cs @@ -1,5 +1,4 @@ using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Governance.Abstractions.Exceptions; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; using ModularityKit.Mutator.Governance.Abstractions.Requests; using ModularityKit.Mutator.Governance.Abstractions.Storage; @@ -12,6 +11,7 @@ namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle; public sealed class MutationRequestLifecycleManager(IMutationRequestStore requestStore) : IMutationRequestLifecycleManager { private readonly IMutationRequestStore _requestStore = requestStore ?? throw new ArgumentNullException(nameof(requestStore)); + private readonly MutationRequestTransitionExecutor _transitionExecutor = new(requestStore); public async Task Submit( MutationRequest request, @@ -19,8 +19,7 @@ public async Task Submit( { ArgumentNullException.ThrowIfNull(request); - await _requestStore.Store(request, cancellationToken).ConfigureAwait(false); - return request; + return await _requestStore.Create(request, cancellationToken).ConfigureAwait(false); } public Task MoveToPending( @@ -32,7 +31,7 @@ public Task MoveToPending( IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default) { - return Transition( + return _transitionExecutor.Execute( requestId, MutationRequestStatus.Pending, MutationRequestDecisionType.Pending, @@ -54,7 +53,7 @@ public Task Approve( IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default) { - return Transition( + return _transitionExecutor.Execute( requestId, MutationRequestStatus.Approved, MutationRequestDecisionType.Approved, @@ -75,13 +74,13 @@ public Task Reject( IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default) { - return Transition( + return _transitionExecutor.Execute( requestId, MutationRequestStatus.Rejected, MutationRequestDecisionType.Rejected, decisionContext, reason, - ClearPendingState, + MutationRequestLifecycleState.ClearPendingState, metadata, cancellationToken); } @@ -93,13 +92,13 @@ public Task Cancel( IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default) { - return Transition( + return _transitionExecutor.Execute( requestId, MutationRequestStatus.Canceled, MutationRequestDecisionType.Canceled, decisionContext, reason, - ClearPendingState, + MutationRequestLifecycleState.ClearPendingState, metadata, cancellationToken); } @@ -111,13 +110,13 @@ public Task Expire( IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default) { - return Transition( + return _transitionExecutor.Execute( requestId, MutationRequestStatus.Expired, MutationRequestDecisionType.Expired, decisionContext, reason, - ClearPendingState, + MutationRequestLifecycleState.ClearPendingState, metadata, cancellationToken); } @@ -163,20 +162,20 @@ public Task Supersede( if (string.IsNullOrWhiteSpace(supersedingRequestId)) throw new ArgumentException("Superseding request ID is required.", nameof(supersedingRequestId)); - var transitionMetadata = MergeMetadata( + var transitionMetadata = MutationRequestLifecycleState.MergeMetadata( metadata, new Dictionary { ["SupersedingRequestId"] = supersedingRequestId }); - return Transition( + return _transitionExecutor.Execute( requestId, MutationRequestStatus.Superseded, MutationRequestDecisionType.Superseded, decisionContext, reason ?? $"Superseded by request '{supersedingRequestId}'.", - ClearPendingState, + MutationRequestLifecycleState.ClearPendingState, transitionMetadata, cancellationToken); } @@ -188,13 +187,13 @@ public Task MarkExecuted( IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default) { - return Transition( + return _transitionExecutor.Execute( requestId, MutationRequestStatus.Executed, MutationRequestDecisionType.Executed, decisionContext, reason, - ClearPendingState, + MutationRequestLifecycleState.ClearPendingState, metadata, cancellationToken); } @@ -214,122 +213,4 @@ public Task> GetPendingByStateId( return _requestStore.GetPendingByStateId(stateId, reason, cancellationToken); } - private async Task Transition( - string requestId, - MutationRequestStatus targetStatus, - MutationRequestDecisionType decisionType, - MutationContext decisionContext, - string? reason, - Func applyState, - IReadOnlyDictionary? metadata, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(requestId)) - throw new ArgumentException("Request ID is required.", nameof(requestId)); - - ArgumentNullException.ThrowIfNull(decisionContext); - ArgumentNullException.ThrowIfNull(applyState); - - var request = await GetRequired(requestId, cancellationToken).ConfigureAwait(false); - - ValidateTransition(request.Status, targetStatus, request.RequestId); - - var decision = MutationRequestDecision.Create( - decisionType, - decisionContext, - reason, - metadata); - - var updatedRequest = applyState(request) with - { - Status = targetStatus, - UpdatedAt = decision.Timestamp, - Decisions = [.. request.Decisions, decision] - }; - - await _requestStore.Store(updatedRequest, cancellationToken).ConfigureAwait(false); - return updatedRequest; - } - - private async Task GetRequired( - string requestId, - CancellationToken cancellationToken) - { - var request = await _requestStore.Get(requestId, cancellationToken).ConfigureAwait(false); - - if (request is null) - throw new MutationRequestNotFoundException(requestId); - - return request; - } - - private static void ValidateTransition( - MutationRequestStatus currentStatus, - MutationRequestStatus targetStatus, - string requestId) - { - if (currentStatus == targetStatus) - throw new InvalidMutationRequestTransitionException(requestId, currentStatus, targetStatus); - - var isValid = currentStatus switch - { - MutationRequestStatus.Created => targetStatus is - MutationRequestStatus.Pending or - MutationRequestStatus.Approved or - MutationRequestStatus.Canceled or - MutationRequestStatus.Superseded, - MutationRequestStatus.Pending => targetStatus is - MutationRequestStatus.Approved or - MutationRequestStatus.Rejected or - MutationRequestStatus.Canceled or - MutationRequestStatus.Expired or - MutationRequestStatus.Superseded, - MutationRequestStatus.Approved => targetStatus is - MutationRequestStatus.Pending or - MutationRequestStatus.Rejected or - MutationRequestStatus.Canceled or - MutationRequestStatus.Superseded or - MutationRequestStatus.Executed, - MutationRequestStatus.Rejected => false, - MutationRequestStatus.Canceled => false, - MutationRequestStatus.Expired => false, - MutationRequestStatus.Superseded => false, - MutationRequestStatus.Executed => false, - _ => false - }; - - if (!isValid) - throw new InvalidMutationRequestTransitionException(requestId, currentStatus, targetStatus); - } - - private static MutationRequest ClearPendingState(MutationRequest request) - { - return request with - { - PendingReason = null, - ExpiresAt = null - }; - } - - private static IReadOnlyDictionary MergeMetadata( - IReadOnlyDictionary? metadata, - IReadOnlyDictionary appended) - { - var merged = new Dictionary(); - - if (metadata is not null) - { - foreach (var pair in metadata) - { - merged[pair.Key] = pair.Value; - } - } - - foreach (var pair in appended) - { - merged[pair.Key] = pair.Value; - } - - return merged; - } } diff --git a/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleState.cs b/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleState.cs new file mode 100644 index 0000000..9e63b70 --- /dev/null +++ b/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleState.cs @@ -0,0 +1,46 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests; + +namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle; + +/// +/// Provides shared state transformations and metadata helpers for lifecycle transitions. +/// +internal static class MutationRequestLifecycleState +{ + /// + /// Clears pending-only fields once a request leaves the pending lifecycle. + /// + public static MutationRequest ClearPendingState(MutationRequest request) + { + return request with + { + PendingReason = null, + ExpiresAt = null + }; + } + + /// + /// Merges transition metadata with appended runtime metadata values. + /// + public static IReadOnlyDictionary MergeMetadata( + IReadOnlyDictionary? metadata, + IReadOnlyDictionary appended) + { + var merged = new Dictionary(); + + if (metadata is not null) + { + foreach (var pair in metadata) + { + merged[pair.Key] = pair.Value; + } + } + + foreach (var pair in appended) + { + merged[pair.Key] = pair.Value; + } + + return merged; + } +} diff --git a/src/Governance/Runtime/Lifecycle/MutationRequestTransitionExecutor.cs b/src/Governance/Runtime/Lifecycle/MutationRequestTransitionExecutor.cs new file mode 100644 index 0000000..f0388fe --- /dev/null +++ b/src/Governance/Runtime/Lifecycle/MutationRequestTransitionExecutor.cs @@ -0,0 +1,76 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Storage; + +namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle; + +/// +/// Executes a single guarded lifecycle transition for a governed mutation request. +/// +internal sealed class MutationRequestTransitionExecutor(IMutationRequestStore requestStore) +{ + private readonly IMutationRequestStore _requestStore = requestStore ?? throw new ArgumentNullException(nameof(requestStore)); + + /// + /// Loads the current request, validates the target transition, appends the decision, and persists the new revision. + /// + public async Task Execute( + string requestId, + MutationRequestStatus targetStatus, + MutationRequestDecisionType decisionType, + MutationContext decisionContext, + string? reason, + Func applyState, + IReadOnlyDictionary? metadata, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(requestId)) + throw new ArgumentException("Request ID is required.", nameof(requestId)); + + ArgumentNullException.ThrowIfNull(decisionContext); + ArgumentNullException.ThrowIfNull(applyState); + + var request = await GetRequired(requestId, cancellationToken).ConfigureAwait(false); + + MutationRequestTransitionValidator.Validate(request.Status, targetStatus, request.RequestId); + + var decision = MutationRequestDecision.Create( + decisionType, + decisionContext, + reason, + metadata); + + var updatedRequest = applyState(request) with + { + Status = targetStatus, + UpdatedAt = decision.Timestamp, + Decisions = [.. request.Decisions, decision] + }; + + var persistedRequest = await _requestStore + .TryStore(updatedRequest, request.Revision, cancellationToken) + .ConfigureAwait(false); + + if (persistedRequest is null) + throw new MutationRequestConcurrencyException(request.RequestId, request.Revision); + + return persistedRequest; + } + + /// + /// Loads a required request or raises a governance not-found exception. + /// + private async Task GetRequired( + string requestId, + CancellationToken cancellationToken) + { + var request = await _requestStore.Get(requestId, cancellationToken).ConfigureAwait(false); + + if (request is null) + throw new MutationRequestNotFoundException(requestId); + + return request; + } +} diff --git a/src/Governance/Runtime/Lifecycle/MutationRequestTransitionValidator.cs b/src/Governance/Runtime/Lifecycle/MutationRequestTransitionValidator.cs new file mode 100644 index 0000000..bdeec57 --- /dev/null +++ b/src/Governance/Runtime/Lifecycle/MutationRequestTransitionValidator.cs @@ -0,0 +1,57 @@ +using System.Collections.Frozen; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; + +namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle; + +/// +/// Validates whether a governed mutation request can move from one lifecycle status to another. +/// +internal static class MutationRequestTransitionValidator +{ + private static readonly FrozenDictionary> AllowedTransitions = + new Dictionary> + { + [MutationRequestStatus.Created] = + [ + MutationRequestStatus.Pending, + MutationRequestStatus.Approved, + MutationRequestStatus.Canceled, + MutationRequestStatus.Superseded + ], + [MutationRequestStatus.Pending] = + [ + MutationRequestStatus.Approved, + MutationRequestStatus.Rejected, + MutationRequestStatus.Canceled, + MutationRequestStatus.Expired, + MutationRequestStatus.Superseded + ], + [MutationRequestStatus.Approved] = + [ + MutationRequestStatus.Pending, + MutationRequestStatus.Rejected, + MutationRequestStatus.Canceled, + MutationRequestStatus.Superseded, + MutationRequestStatus.Executed + ] + }.ToFrozenDictionary(); + + /// + /// Ensures the target status is allowed for the current request status. + /// + public static void Validate( + MutationRequestStatus currentStatus, + MutationRequestStatus targetStatus, + string requestId) + { + if (currentStatus == targetStatus) + throw new InvalidMutationRequestTransitionException(requestId, currentStatus, targetStatus); + + var isValid = AllowedTransitions.TryGetValue(currentStatus, out var validTargets) + && validTargets.Contains(targetStatus); + + if (!isValid) + throw new InvalidMutationRequestTransitionException(requestId, currentStatus, targetStatus); + } +} diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionFactory.cs b/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionFactory.cs new file mode 100644 index 0000000..91d8507 --- /dev/null +++ b/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionFactory.cs @@ -0,0 +1,166 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Resolution; + +namespace ModularityKit.Mutator.Governance.Runtime.Resolution; + +/// +/// Builds concrete version-resolution outcomes and updated request snapshots for governance runtime flows. +/// +internal static class MutationRequestVersionResolutionFactory +{ + /// + /// Builds the non-stale resolution outcome for a request that can proceed immediately. + /// + public static MutationRequestVersionResolution BuildValidated( + MutationRequest request, + string? expectedStateVersion, + string currentStateVersion, + MutationContext resolutionContext) + { + var validatedDecision = MutationRequestDecision.Create( + MutationRequestDecisionType.VersionValidated, + resolutionContext, + reason: string.IsNullOrWhiteSpace(expectedStateVersion) + ? "No expected state version was provided. Request can proceed." + : $"State version '{currentStateVersion}' matches the expected version.", + metadata: CreateVersionMetadata(expectedStateVersion, currentStateVersion)); + + return new MutationRequestVersionResolution + { + Request = AppendDecision(request, validatedDecision), + Outcome = MutationRequestVersionResolutionOutcome.ExecuteApprovedVersion, + ExpectedStateVersion = expectedStateVersion, + CurrentStateVersion = currentStateVersion, + IsStale = false + }; + } + + /// + /// Builds a stale resolution that rejects the request outright. + /// + public static MutationRequestVersionResolution BuildRejectedAsStale( + MutationRequest request, + string currentStateVersion, + MutationContext resolutionContext) + { + var decision = MutationRequestDecision.Create( + MutationRequestDecisionType.RejectedAsStale, + resolutionContext, + reason: BuildStaleReason(request.ExpectedStateVersion!, currentStateVersion), + metadata: CreateVersionMetadata(request.ExpectedStateVersion, currentStateVersion)); + + var updatedRequest = AppendDecision( + request with + { + Status = MutationRequestStatus.Rejected, + PendingReason = null, + UpdatedAt = decision.Timestamp + }, + decision); + + return new MutationRequestVersionResolution + { + Request = updatedRequest, + Outcome = MutationRequestVersionResolutionOutcome.RejectedAsStale, + ExpectedStateVersion = request.ExpectedStateVersion, + CurrentStateVersion = currentStateVersion, + IsStale = true + }; + } + + /// + /// Builds a stale resolution that moves the request back to pending approval on the latest version. + /// + public static MutationRequestVersionResolution BuildRenewedApprovalRequired( + MutationRequest request, + string currentStateVersion, + MutationContext resolutionContext) + { + var decision = MutationRequestDecision.Create( + MutationRequestDecisionType.RenewedApprovalRequired, + resolutionContext, + reason: BuildStaleReason(request.ExpectedStateVersion!, currentStateVersion), + metadata: CreateVersionMetadata(request.ExpectedStateVersion, currentStateVersion)); + + var updatedRequest = AppendDecision( + request with + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval, + ExpectedStateVersion = currentStateVersion, + UpdatedAt = decision.Timestamp + }, + decision); + + return new MutationRequestVersionResolution + { + Request = updatedRequest, + Outcome = MutationRequestVersionResolutionOutcome.RequiresRenewedApproval, + ExpectedStateVersion = request.ExpectedStateVersion, + CurrentStateVersion = currentStateVersion, + IsStale = true + }; + } + + /// + /// Builds a stale resolution that keeps the request approved but requires revalidation on the latest version. + /// + public static MutationRequestVersionResolution BuildRevalidationRequired( + MutationRequest request, + string currentStateVersion, + MutationContext resolutionContext) + { + var decision = MutationRequestDecision.Create( + MutationRequestDecisionType.RevalidationRequired, + resolutionContext, + reason: BuildStaleReason(request.ExpectedStateVersion!, currentStateVersion), + metadata: CreateVersionMetadata(request.ExpectedStateVersion, currentStateVersion)); + + var updatedRequest = AppendDecision( + request with + { + Status = MutationRequestStatus.Approved, + PendingReason = null, + ExpectedStateVersion = currentStateVersion, + UpdatedAt = decision.Timestamp + }, + decision); + + return new MutationRequestVersionResolution + { + Request = updatedRequest, + Outcome = MutationRequestVersionResolutionOutcome.RevalidateOnLatestState, + ExpectedStateVersion = request.ExpectedStateVersion, + CurrentStateVersion = currentStateVersion, + IsStale = true + }; + } + + private static MutationRequest AppendDecision( + MutationRequest request, + MutationRequestDecision decision) + { + return request with + { + Decisions = [.. request.Decisions, decision] + }; + } + + private static string BuildStaleReason(string expectedStateVersion, string currentStateVersion) + { + return $"Request expected state version '{expectedStateVersion}' but current version is '{currentStateVersion}'."; + } + + private static IReadOnlyDictionary CreateVersionMetadata( + string? expectedStateVersion, + string currentStateVersion) + { + return new Dictionary + { + ["ExpectedStateVersion"] = expectedStateVersion ?? string.Empty, + ["CurrentStateVersion"] = currentStateVersion + }; + } +} diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs b/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs index 9b3270f..afcd96d 100644 --- a/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs +++ b/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs @@ -27,158 +27,27 @@ public MutationRequestVersionResolution Resolve( !string.Equals(expectedStateVersion, currentStateVersion, StringComparison.Ordinal); if (!isStale) - { - var validatedDecision = MutationRequestDecision.Create( - MutationRequestDecisionType.VersionValidated, - resolutionContext, - reason: string.IsNullOrWhiteSpace(expectedStateVersion) - ? "No expected state version was provided. Request can proceed." - : $"State version '{currentStateVersion}' matches the expected version.", - metadata: CreateVersionMetadata(expectedStateVersion, currentStateVersion)); - - return new MutationRequestVersionResolution - { - Request = AppendDecision(request, validatedDecision), - Outcome = MutationRequestVersionResolutionOutcome.ExecuteApprovedVersion, - ExpectedStateVersion = expectedStateVersion, - CurrentStateVersion = currentStateVersion, - IsStale = false - }; - } + return MutationRequestVersionResolutionFactory.BuildValidated( + request, + expectedStateVersion, + currentStateVersion, + resolutionContext); return strategy switch { - VersionedRequestResolutionStrategy.RejectStale => BuildRejectedAsStale( + VersionedRequestResolutionStrategy.RejectStale => MutationRequestVersionResolutionFactory.BuildRejectedAsStale( request, currentStateVersion, resolutionContext), - VersionedRequestResolutionStrategy.RequireRenewedApproval => BuildRenewedApprovalRequired( + VersionedRequestResolutionStrategy.RequireRenewedApproval => MutationRequestVersionResolutionFactory.BuildRenewedApprovalRequired( request, currentStateVersion, resolutionContext), - VersionedRequestResolutionStrategy.RevalidateOnLatestState => BuildRevalidationRequired( + VersionedRequestResolutionStrategy.RevalidateOnLatestState => MutationRequestVersionResolutionFactory.BuildRevalidationRequired( request, currentStateVersion, resolutionContext), _ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown stale-resolution strategy.") }; } - - private static MutationRequestVersionResolution BuildRejectedAsStale( - MutationRequest request, - string currentStateVersion, - MutationContext resolutionContext) - { - var decision = MutationRequestDecision.Create( - MutationRequestDecisionType.RejectedAsStale, - resolutionContext, - reason: BuildStaleReason(request.ExpectedStateVersion!, currentStateVersion), - metadata: CreateVersionMetadata(request.ExpectedStateVersion, currentStateVersion)); - - var updatedRequest = AppendDecision( - request with - { - Status = MutationRequestStatus.Rejected, - PendingReason = null, - UpdatedAt = decision.Timestamp - }, - decision); - - return new MutationRequestVersionResolution - { - Request = updatedRequest, - Outcome = MutationRequestVersionResolutionOutcome.RejectedAsStale, - ExpectedStateVersion = request.ExpectedStateVersion, - CurrentStateVersion = currentStateVersion, - IsStale = true - }; - } - - private static MutationRequestVersionResolution BuildRenewedApprovalRequired( - MutationRequest request, - string currentStateVersion, - MutationContext resolutionContext) - { - var decision = MutationRequestDecision.Create( - MutationRequestDecisionType.RenewedApprovalRequired, - resolutionContext, - reason: BuildStaleReason(request.ExpectedStateVersion!, currentStateVersion), - metadata: CreateVersionMetadata(request.ExpectedStateVersion, currentStateVersion)); - - var updatedRequest = AppendDecision( - request with - { - Status = MutationRequestStatus.Pending, - PendingReason = PendingMutationReason.Approval, - ExpectedStateVersion = currentStateVersion, - UpdatedAt = decision.Timestamp - }, - decision); - - return new MutationRequestVersionResolution - { - Request = updatedRequest, - Outcome = MutationRequestVersionResolutionOutcome.RequiresRenewedApproval, - ExpectedStateVersion = request.ExpectedStateVersion, - CurrentStateVersion = currentStateVersion, - IsStale = true - }; - } - - private static MutationRequestVersionResolution BuildRevalidationRequired( - MutationRequest request, - string currentStateVersion, - MutationContext resolutionContext) - { - var decision = MutationRequestDecision.Create( - MutationRequestDecisionType.RevalidationRequired, - resolutionContext, - reason: BuildStaleReason(request.ExpectedStateVersion!, currentStateVersion), - metadata: CreateVersionMetadata(request.ExpectedStateVersion, currentStateVersion)); - - var updatedRequest = AppendDecision( - request with - { - Status = MutationRequestStatus.Approved, - PendingReason = null, - ExpectedStateVersion = currentStateVersion, - UpdatedAt = decision.Timestamp - }, - decision); - - return new MutationRequestVersionResolution - { - Request = updatedRequest, - Outcome = MutationRequestVersionResolutionOutcome.RevalidateOnLatestState, - ExpectedStateVersion = request.ExpectedStateVersion, - CurrentStateVersion = currentStateVersion, - IsStale = true - }; - } - - private static MutationRequest AppendDecision( - MutationRequest request, - MutationRequestDecision decision) - { - return request with - { - Decisions = [.. request.Decisions, decision] - }; - } - - private static string BuildStaleReason(string expectedStateVersion, string currentStateVersion) - { - return $"Request expected state version '{expectedStateVersion}' but current version is '{currentStateVersion}'."; - } - - private static IReadOnlyDictionary CreateVersionMetadata( - string? expectedStateVersion, - string currentStateVersion) - { - return new Dictionary - { - ["ExpectedStateVersion"] = expectedStateVersion ?? string.Empty, - ["CurrentStateVersion"] = currentStateVersion - }; - } } diff --git a/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs index 9cc2e2c..74d40c6 100644 --- a/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs +++ b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs @@ -1,6 +1,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; using ModularityKit.Mutator.Governance.Abstractions.Requests; using ModularityKit.Mutator.Governance.Abstractions.Storage; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions; namespace ModularityKit.Mutator.Governance.Runtime.Storage; @@ -13,7 +14,7 @@ public sealed class InMemoryMutationRequestStore : IMutationRequestStore private readonly Dictionary _requests = new(); private readonly Lock _lock = new(); - public Task Store( + public Task Create( MutationRequest request, CancellationToken cancellationToken = default) { @@ -21,10 +22,42 @@ public Task Store( lock (_lock) { - _requests[request.RequestId] = request; + if (_requests.ContainsKey(request.RequestId)) + throw new MutationRequestAlreadyExistsException(request.RequestId); + + var persistedRequest = request with + { + Revision = 0 + }; + + _requests[request.RequestId] = persistedRequest; + return Task.FromResult(persistedRequest); } + } + + public Task TryStore( + MutationRequest request, + long expectedRevision, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + lock (_lock) + { + if (!_requests.TryGetValue(request.RequestId, out var currentRequest)) + return Task.FromResult(null); - return Task.CompletedTask; + if (currentRequest.Revision != expectedRevision) + return Task.FromResult(null); + + var persistedRequest = request with + { + Revision = expectedRevision + 1 + }; + + _requests[request.RequestId] = persistedRequest; + return Task.FromResult(persistedRequest); + } } public Task Get(