From e261e34853f083e1f41108679146f31a9523dd20 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 22 Jun 2026 16:54:47 +0200 Subject: [PATCH 1/2] Test: Add governance runtime characterization coverage Added - Governance test project for request lifecycle and version resolution behavior - Segregated characterization tests for lifecycle atomicity, store overwrite semantics, and resolution persistence - Shared test support for seeded requests and stale-snapshot stores Result Governance runtime risks now have executable characterization coverage so concurrency, store-contract, and resolution-persistence behavior can be validated before changing the execution model. --- ModularityKit.Mutator.slnx | 4 +- .../MutationRequestLifecycleAtomicityTests.cs | 33 +++++++++ .../MutationRequestStoreContractTests.cs | 41 +++++++++++ ...ularityKit.Mutator.Governance.Tests.csproj | 23 ++++++ ...equestVersionResolutionPersistenceTests.cs | 36 ++++++++++ .../TestSupport/MutationRequestTestFactory.cs | 42 +++++++++++ .../StaleSnapshotMutationRequestStore.cs | 70 +++++++++++++++++++ 7 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs diff --git a/ModularityKit.Mutator.slnx b/ModularityKit.Mutator.slnx index d57cf35..5a304d3 100644 --- a/ModularityKit.Mutator.slnx +++ b/ModularityKit.Mutator.slnx @@ -11,7 +11,9 @@ - + + + diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs new file mode 100644 index 0000000..dd65cba --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs @@ -0,0 +1,33 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Runtime.Lifecycle; +using ModularityKit.Mutator.Governance.Tests.TestSupport; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Lifecycle; + +public sealed class MutationRequestLifecycleAtomicityTests +{ + [Fact] + public async Task Stale_snapshot_transition_can_succeed_after_a_prior_lifecycle_update() + { + var request = MutationRequestTestFactory.CreatePendingRequest(); + var store = new StaleSnapshotMutationRequestStore(request); + var manager = new MutationRequestLifecycleManager(store); + + var approved = await manager.Approve( + request.RequestId, + MutationContext.User("approver", "Approver", "Approve request")); + + var canceled = await manager.Cancel( + request.RequestId, + MutationContext.User("operator", "Operator", "Cancel request")); + + Assert.Equal(2, 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 }); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs new file mode 100644 index 0000000..a3dbdd2 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs @@ -0,0 +1,41 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Runtime.Storage; +using ModularityKit.Mutator.Governance.Tests.TestSupport; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Lifecycle; + +public sealed class MutationRequestStoreContractTests +{ + [Fact] + public async Task Store_contract_allows_blind_overwrite_without_expected_revision_or_status() + { + var store = new InMemoryMutationRequestStore(); + var request = MutationRequestTestFactory.CreatePendingRequest(); + + await store.Store(request); + + var overwritten = request with + { + Status = MutationRequestStatus.Canceled, + PendingReason = null, + Decisions = + [ + .. request.Decisions, + MutationRequestDecision.Create( + MutationRequestDecisionType.Canceled, + MutationContext.User("operator", "Operator", "Canceled without guard")) + ] + }; + + await store.Store(overwritten); + + var loaded = await store.Get(request.RequestId); + + Assert.NotNull(loaded); + Assert.Equal(MutationRequestStatus.Canceled, loaded.Status); + Assert.Equal(overwritten.Decisions.Count, loaded.Decisions.Count); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj b/Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj new file mode 100644 index 0000000..ad54f15 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs new file mode 100644 index 0000000..0c8197c --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs @@ -0,0 +1,36 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Resolution; +using ModularityKit.Mutator.Governance.Runtime.Resolution; +using ModularityKit.Mutator.Governance.Runtime.Storage; +using ModularityKit.Mutator.Governance.Tests.TestSupport; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Resolution; + +public sealed class MutationRequestVersionResolutionPersistenceTests +{ + [Fact] + public async Task Resolve_does_not_persist_decision_history_unless_caller_saves_the_result() + { + var store = new InMemoryMutationRequestStore(); + var resolver = new MutationRequestVersionResolver(); + var request = MutationRequestTestFactory.CreateApprovedSecurityRequest("v10"); + + await store.Store(request); + + var resolution = resolver.Resolve( + request, + currentStateVersion: "v15", + resolutionContext: MutationContext.User("approver", "Approver", "Resolve request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); + + var loaded = await store.Get(request.RequestId); + + Assert.NotNull(loaded); + Assert.Equal(2, loaded.Decisions.Count); + Assert.Equal(3, resolution.Request.Decisions.Count); + Assert.Equal(MutationRequestStatus.Approved, loaded.Status); + Assert.Equal(MutationRequestStatus.Rejected, resolution.Request.Status); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs new file mode 100644 index 0000000..c5fe195 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs @@ -0,0 +1,42 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Requests; + +namespace ModularityKit.Mutator.Governance.Tests.TestSupport; + +internal static class MutationRequestTestFactory +{ + public static MutationRequest CreatePendingRequest() + { + return MutationRequest.Pending( + stateId: "tenant-42:quota", + stateType: "QuotaPolicy", + mutationType: "IncreaseQuotaMutation", + intent: new MutationIntent + { + OperationName = "IncreaseQuota", + Category = "Billing", + Description = "Raise quota" + }, + context: MutationContext.User("alice", "Alice", "Need more quota"), + pendingReason: PendingMutationReason.Approval, + expectedStateVersion: "v12"); + } + + public static MutationRequest CreateApprovedSecurityRequest(string expectedStateVersion) + { + return MutationRequest.Approved( + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated access" + }, + context: MutationContext.User("requester", "Requester", "Need access"), + expectedStateVersion: expectedStateVersion); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs new file mode 100644 index 0000000..35185a9 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs @@ -0,0 +1,70 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Storage; + +namespace ModularityKit.Mutator.Governance.Tests.TestSupport; + +internal sealed class StaleSnapshotMutationRequestStore(MutationRequest seedRequest) : IMutationRequestStore +{ + private readonly object _gate = new(); + private readonly MutationRequest _seedRequest = seedRequest; + private readonly List _getSnapshots = []; + private MutationRequest _current = seedRequest; + + public int StoreCount { get; private set; } + + public MutationRequest Current + { + get + { + lock (_gate) + { + return _current; + } + } + } + + public IReadOnlyList GetSnapshots => _getSnapshots; + + public Task Store( + MutationRequest request, + CancellationToken cancellationToken = default) + { + lock (_gate) + { + StoreCount++; + _current = request; + } + + return Task.CompletedTask; + } + + public Task Get( + string requestId, + CancellationToken cancellationToken = default) + { + lock (_gate) + { + var snapshot = _seedRequest; + _getSnapshots.Add(snapshot); + + return Task.FromResult(snapshot); + } + } + + public Task> GetByStateId( + string stateId, + CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + public Task> GetPendingByStateId( + string stateId, + PendingMutationReason? reason = null, + CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + public Task> GetPending( + PendingMutationReason? reason = null, + CancellationToken cancellationToken = default) + => Task.FromResult>([]); +} From 040ecbdec6a7f7c8ab98d40916d9891e55ed9b96 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 22 Jun 2026 19:13:39 +0200 Subject: [PATCH 2/2] Feat: Add optimistic concurrency semantics to governance storage --- .../MutationRequestLifecycleAtomicityTests.cs | 19 +- .../MutationRequestStoreContractTests.cs | 48 ++++- ...equestVersionResolutionPersistenceTests.cs | 2 +- .../StaleSnapshotMutationRequestStore.cs | 29 ++- .../MutationRequestAlreadyExistsException.cs | 15 ++ .../MutationRequestConcurrencyException.cs | 23 +++ .../Abstractions/Requests/MutationRequest.cs | 5 + .../Storage/IMutationRequestStore.cs | 13 +- src/Governance/README.md | 2 + .../MutationRequestLifecycleManager.cs | 149 ++-------------- .../MutationRequestLifecycleState.cs | 46 +++++ .../MutationRequestTransitionExecutor.cs | 76 ++++++++ .../MutationRequestTransitionValidator.cs | 57 ++++++ ...MutationRequestVersionResolutionFactory.cs | 166 ++++++++++++++++++ .../MutationRequestVersionResolver.cs | 147 +--------------- .../Storage/InMemoryMutationRequestStore.cs | 39 +++- 16 files changed, 537 insertions(+), 299 deletions(-) create mode 100644 src/Governance/Abstractions/Exceptions/MutationRequestAlreadyExistsException.cs create mode 100644 src/Governance/Abstractions/Exceptions/MutationRequestConcurrencyException.cs create mode 100644 src/Governance/Runtime/Lifecycle/MutationRequestLifecycleState.cs create mode 100644 src/Governance/Runtime/Lifecycle/MutationRequestTransitionExecutor.cs create mode 100644 src/Governance/Runtime/Lifecycle/MutationRequestTransitionValidator.cs create mode 100644 src/Governance/Runtime/Resolution/MutationRequestVersionResolutionFactory.cs 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..51e165e 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,61 @@ 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(