From ab6cb8f63c9ce3257c841df76f90802a04c23a26 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 22 Jun 2026 20:22:28 +0200 Subject: [PATCH] Feat: Persist governance version resolution outcomes --- .../Governance/VersionedResolution/Program.cs | 2 +- .../Governance/VersionedResolution/README.md | 41 +++++- .../GovernanceVersionedResolutionScenario.cs | 16 ++- ...equestVersionResolutionPersistenceTests.cs | 43 +++++++ ...MutationRequestVersionResolutionManager.cs | 19 +++ src/Governance/README.md | 2 + .../MutationRequestVersionEvaluation.cs | 22 ++++ .../MutationRequestVersionEvaluator.cs | 33 +++++ ...MutationRequestVersionResolutionFactory.cs | 119 +++++++----------- ...MutationRequestVersionResolutionManager.cs | 54 ++++++++ .../MutationRequestVersionResolutionState.cs | 115 +++++++++++++++++ .../MutationRequestVersionResolver.cs | 18 +-- 12 files changed, 394 insertions(+), 90 deletions(-) create mode 100644 src/Governance/Abstractions/Resolution/IMutationRequestVersionResolutionManager.cs create mode 100644 src/Governance/Runtime/Resolution/MutationRequestVersionEvaluation.cs create mode 100644 src/Governance/Runtime/Resolution/MutationRequestVersionEvaluator.cs create mode 100644 src/Governance/Runtime/Resolution/MutationRequestVersionResolutionManager.cs create mode 100644 src/Governance/Runtime/Resolution/MutationRequestVersionResolutionState.cs diff --git a/Examples/Governance/VersionedResolution/Program.cs b/Examples/Governance/VersionedResolution/Program.cs index 800a36f..f98230d 100644 --- a/Examples/Governance/VersionedResolution/Program.cs +++ b/Examples/Governance/VersionedResolution/Program.cs @@ -1,3 +1,3 @@ using VersionedResolution.Scenarios; -GovernanceVersionedResolutionScenario.Run(); +await GovernanceVersionedResolutionScenario.Run(); diff --git a/Examples/Governance/VersionedResolution/README.md b/Examples/Governance/VersionedResolution/README.md index a3438ba..cabddd0 100644 --- a/Examples/Governance/VersionedResolution/README.md +++ b/Examples/Governance/VersionedResolution/README.md @@ -1,6 +1,6 @@ # Governance VersionedResolution -This example shows how `MutationRequestVersionResolver` handles requests that were approved against an older state version. +This example shows how `MutationRequestVersionResolver` handles requests that were approved against an older state version and how the persisted runtime path stores the resulting decision. It is the direct runnable example for the semantics introduced around `ExpectedStateVersion` and stale request handling. @@ -10,13 +10,15 @@ It is the direct runnable example for the semantics introduced around `ExpectedS - resolving stale requests with `RejectStale` - resolving stale requests with `RequireRenewedApproval` - resolving stale requests with `RevalidateOnLatestState` +- persisting a resolved outcome through `MutationRequestVersionResolutionManager` - inspecting the resulting lifecycle state and appended decision history ## Key files - [`Program.cs`](Program.cs) - [`Scenarios/GovernanceVersionedResolutionScenario.cs`](Scenarios/GovernanceVersionedResolutionScenario.cs) -- [`src/Governance/Runtime/MutationRequestVersionResolver.cs`](../../../src/Governance/Runtime/MutationRequestVersionResolver.cs) +- [`src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs`](../../../src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs) +- [`src/Governance/Runtime/Resolution/MutationRequestVersionResolutionManager.cs`](../../../src/Governance/Runtime/Resolution/MutationRequestVersionResolutionManager.cs) - [`src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs`](../../../src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs) - [`src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs`](../../../src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs) @@ -26,12 +28,45 @@ It is the direct runnable example for the semantics introduced around `ExpectedS dotnet run --project Examples/Governance/VersionedResolution/VersionedResolution.csproj ``` +## Example usage + +```csharp +var store = new InMemoryMutationRequestStore(); +var resolver = new MutationRequestVersionResolver(); +var manager = new MutationRequestVersionResolutionManager(store, resolver); + +var request = await store.Create( + MutationRequest.Approved( + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated role to tenant operator" + }, + context: MutationContext.User("requester-1", "Requester One", "Need elevated access for incident"), + expectedStateVersion: "v10")); + +var resolution = await manager.ResolveAndStore( + request.RequestId, + currentStateVersion: "v15", + resolutionContext: MutationContext.User("approver-5", "Approver Five", "Persist resolved request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); + +Console.WriteLine(resolution.Outcome); +Console.WriteLine(resolution.Request.Status); +Console.WriteLine(resolution.Request.Decisions[^1].Type); +``` + ## Expected output -The sample prints one block per resolution strategy and shows: +The sample prints one block per resolution strategy and one persisted-resolution block. It shows: - selected outcome - whether the request was stale - resulting request status - updated expected version - last decision recorded during resolution +- persisted request revision for the runtime path diff --git a/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs b/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs index 2d0d921..f993c23 100644 --- a/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs +++ b/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs @@ -3,14 +3,17 @@ using ModularityKit.Mutator.Governance.Abstractions.Requests; using ModularityKit.Mutator.Governance.Abstractions.Resolution; using ModularityKit.Mutator.Governance.Runtime.Resolution; +using ModularityKit.Mutator.Governance.Runtime.Storage; namespace VersionedResolution.Scenarios; internal static class GovernanceVersionedResolutionScenario { - public static void Run() + public static async Task Run() { var resolver = new MutationRequestVersionResolver(); + var store = new InMemoryMutationRequestStore(); + var manager = new MutationRequestVersionResolutionManager(store, resolver); PrintSection("Current Version Matches Expected Version"); PrintResolution( @@ -43,6 +46,17 @@ public static void Run() currentStateVersion: "v15", resolutionContext: MutationContext.User("approver-4", "Approver Four", "Revalidate on the latest state"), strategy: VersionedRequestResolutionStrategy.RevalidateOnLatestState)); + + PrintSection("Persisted Resolution Path"); + var persistedRequest = await store.Create(CreateApprovedRequest("v10")); + var persistedResolution = await manager.ResolveAndStore( + persistedRequest.RequestId, + currentStateVersion: "v15", + resolutionContext: MutationContext.User("approver-5", "Approver Five", "Persist resolved request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); + + PrintResolution(persistedResolution); + Console.WriteLine($"Persisted revision: {persistedResolution.Request.Revision}"); } private static MutationRequest CreateApprovedRequest(string expectedStateVersion) diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs index db73a77..7d28a5e 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs @@ -1,5 +1,7 @@ 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.Resolution; using ModularityKit.Mutator.Governance.Runtime.Resolution; using ModularityKit.Mutator.Governance.Runtime.Storage; @@ -33,4 +35,45 @@ public async Task Resolve_does_not_persist_decision_history_unless_caller_saves_ Assert.Equal(MutationRequestStatus.Approved, loaded.Status); Assert.Equal(MutationRequestStatus.Rejected, resolution.Request.Status); } + + [Fact] + public async Task ResolveAndStore_persists_decision_history_and_state() + { + var store = new InMemoryMutationRequestStore(); + var resolver = new MutationRequestVersionResolver(); + var manager = new MutationRequestVersionResolutionManager(store, resolver); + var request = await store.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10")); + + var resolution = await manager.ResolveAndStore( + request.RequestId, + currentStateVersion: "v15", + resolutionContext: MutationContext.User("approver", "Approver", "Resolve request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); + + var loaded = await store.Get(request.RequestId); + + Assert.NotNull(loaded); + Assert.Equal(3, loaded.Decisions.Count); + Assert.Equal(MutationRequestDecisionType.RejectedAsStale, loaded.Decisions[^1].Type); + Assert.Equal(MutationRequestStatus.Rejected, loaded.Status); + Assert.Equal(1, loaded.Revision); + Assert.Equal(loaded, resolution.Request); + } + + [Fact] + public async Task ResolveAndStore_throws_not_found_for_missing_request() + { + var store = new InMemoryMutationRequestStore(); + var resolver = new MutationRequestVersionResolver(); + var manager = new MutationRequestVersionResolutionManager(store, resolver); + + var exception = await Assert.ThrowsAsync(() => + manager.ResolveAndStore( + "missing-request", + currentStateVersion: "v15", + resolutionContext: MutationContext.User("approver", "Approver", "Resolve request"), + strategy: VersionedRequestResolutionStrategy.RejectStale)); + + Assert.Equal("missing-request", exception.RequestId); + } } diff --git a/src/Governance/Abstractions/Resolution/IMutationRequestVersionResolutionManager.cs b/src/Governance/Abstractions/Resolution/IMutationRequestVersionResolutionManager.cs new file mode 100644 index 0000000..a3e71d3 --- /dev/null +++ b/src/Governance/Abstractions/Resolution/IMutationRequestVersionResolutionManager.cs @@ -0,0 +1,19 @@ +using ModularityKit.Mutator.Abstractions.Context; + +namespace ModularityKit.Mutator.Governance.Abstractions.Resolution; + +/// +/// Persists version-aware resolution outcomes for governed mutation requests. +/// +public interface IMutationRequestVersionResolutionManager +{ + /// + /// Resolves a persisted request against the current state version and stores the resulting request state and decision history. + /// + Task ResolveAndStore( + string requestId, + string currentStateVersion, + MutationContext resolutionContext, + VersionedRequestResolutionStrategy strategy, + CancellationToken cancellationToken = default); +} diff --git a/src/Governance/README.md b/src/Governance/README.md index 3cd9dde..aa92834 100644 --- a/src/Governance/README.md +++ b/src/Governance/README.md @@ -35,6 +35,7 @@ Key types: - `IMutationRequestStore` - `IMutationRequestLifecycleManager` - `IMutationRequestVersionResolver` +- `IMutationRequestVersionResolutionManager` - `MutationRequestVersionResolution` - `MutationRequestVersionResolutionOutcome` - `VersionedRequestResolutionStrategy` @@ -50,6 +51,7 @@ The initial runtime layer currently provides: - `Runtime/Storage/InMemoryMutationRequestStore` - `Runtime/Lifecycle/MutationRequestLifecycleManager` - `Runtime/Resolution/MutationRequestVersionResolver` +- `Runtime/Resolution/MutationRequestVersionResolutionManager` This keeps the first version small while leaving room for later persistence providers such as Entity Framework Core or PostgreSQL-backed governance stores. diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionEvaluation.cs b/src/Governance/Runtime/Resolution/MutationRequestVersionEvaluation.cs new file mode 100644 index 0000000..c7ab2bc --- /dev/null +++ b/src/Governance/Runtime/Resolution/MutationRequestVersionEvaluation.cs @@ -0,0 +1,22 @@ +namespace ModularityKit.Mutator.Governance.Runtime.Resolution; + +/// +/// Describes how a governed request version compares to the currently observed state version. +/// +internal sealed record MutationRequestVersionEvaluation +{ + /// + /// Expected version captured on the request before resolution. + /// + public string? ExpectedStateVersion { get; init; } + + /// + /// Current version observed by the runtime during resolution. + /// + public string CurrentStateVersion { get; init; } = string.Empty; + + /// + /// Indicates whether the expected and current versions differ. + /// + public bool IsStale { get; init; } +} diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionEvaluator.cs b/src/Governance/Runtime/Resolution/MutationRequestVersionEvaluator.cs new file mode 100644 index 0000000..068a7da --- /dev/null +++ b/src/Governance/Runtime/Resolution/MutationRequestVersionEvaluator.cs @@ -0,0 +1,33 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests; + +namespace ModularityKit.Mutator.Governance.Runtime.Resolution; + +/// +/// Evaluates whether a governed request still matches the currently observed state version. +/// +internal static class MutationRequestVersionEvaluator +{ + /// + /// Compares the request expected version with the current state version and returns a normalized evaluation model. + /// + public static MutationRequestVersionEvaluation Evaluate( + MutationRequest request, + string currentStateVersion) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(currentStateVersion)) + throw new ArgumentException("Current state version is required.", nameof(currentStateVersion)); + + var expectedStateVersion = request.ExpectedStateVersion; + var isStale = !string.IsNullOrWhiteSpace(expectedStateVersion) + && !string.Equals(expectedStateVersion, currentStateVersion, StringComparison.Ordinal); + + return new MutationRequestVersionEvaluation + { + ExpectedStateVersion = expectedStateVersion, + CurrentStateVersion = currentStateVersion, + IsStale = isStale + }; + } +} diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionFactory.cs b/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionFactory.cs index 91d8507..a2f95fe 100644 --- a/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionFactory.cs +++ b/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionFactory.cs @@ -15,24 +15,25 @@ internal static class MutationRequestVersionResolutionFactory /// public static MutationRequestVersionResolution BuildValidated( MutationRequest request, - string? expectedStateVersion, - string currentStateVersion, + MutationRequestVersionEvaluation evaluation, 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)); + reason: MutationRequestVersionResolutionState.BuildValidatedReason( + evaluation.ExpectedStateVersion, + evaluation.CurrentStateVersion), + metadata: MutationRequestVersionResolutionState.CreateVersionMetadata( + evaluation.ExpectedStateVersion, + evaluation.CurrentStateVersion)); return new MutationRequestVersionResolution { - Request = AppendDecision(request, validatedDecision), + Request = MutationRequestVersionResolutionState.AppendDecision(request, validatedDecision), Outcome = MutationRequestVersionResolutionOutcome.ExecuteApprovedVersion, - ExpectedStateVersion = expectedStateVersion, - CurrentStateVersion = currentStateVersion, + ExpectedStateVersion = evaluation.ExpectedStateVersion, + CurrentStateVersion = evaluation.CurrentStateVersion, IsStale = false }; } @@ -42,30 +43,30 @@ public static MutationRequestVersionResolution BuildValidated( /// public static MutationRequestVersionResolution BuildRejectedAsStale( MutationRequest request, - string currentStateVersion, + MutationRequestVersionEvaluation evaluation, MutationContext resolutionContext) { var decision = MutationRequestDecision.Create( MutationRequestDecisionType.RejectedAsStale, resolutionContext, - reason: BuildStaleReason(request.ExpectedStateVersion!, currentStateVersion), - metadata: CreateVersionMetadata(request.ExpectedStateVersion, currentStateVersion)); + reason: MutationRequestVersionResolutionState.BuildStaleReason( + evaluation.ExpectedStateVersion!, + evaluation.CurrentStateVersion), + metadata: MutationRequestVersionResolutionState.CreateVersionMetadata( + evaluation.ExpectedStateVersion, + evaluation.CurrentStateVersion)); - var updatedRequest = AppendDecision( - request with - { - Status = MutationRequestStatus.Rejected, - PendingReason = null, - UpdatedAt = decision.Timestamp - }, + var updatedRequest = MutationRequestVersionResolutionState.ApplyRejectedAsStale( + request, + evaluation.CurrentStateVersion, decision); return new MutationRequestVersionResolution { Request = updatedRequest, Outcome = MutationRequestVersionResolutionOutcome.RejectedAsStale, - ExpectedStateVersion = request.ExpectedStateVersion, - CurrentStateVersion = currentStateVersion, + ExpectedStateVersion = evaluation.ExpectedStateVersion, + CurrentStateVersion = evaluation.CurrentStateVersion, IsStale = true }; } @@ -75,31 +76,30 @@ request with /// public static MutationRequestVersionResolution BuildRenewedApprovalRequired( MutationRequest request, - string currentStateVersion, + MutationRequestVersionEvaluation evaluation, MutationContext resolutionContext) { var decision = MutationRequestDecision.Create( MutationRequestDecisionType.RenewedApprovalRequired, resolutionContext, - reason: BuildStaleReason(request.ExpectedStateVersion!, currentStateVersion), - metadata: CreateVersionMetadata(request.ExpectedStateVersion, currentStateVersion)); + reason: MutationRequestVersionResolutionState.BuildStaleReason( + evaluation.ExpectedStateVersion!, + evaluation.CurrentStateVersion), + metadata: MutationRequestVersionResolutionState.CreateVersionMetadata( + evaluation.ExpectedStateVersion, + evaluation.CurrentStateVersion)); - var updatedRequest = AppendDecision( - request with - { - Status = MutationRequestStatus.Pending, - PendingReason = PendingMutationReason.Approval, - ExpectedStateVersion = currentStateVersion, - UpdatedAt = decision.Timestamp - }, + var updatedRequest = MutationRequestVersionResolutionState.ApplyRenewedApprovalRequired( + request, + evaluation.CurrentStateVersion, decision); return new MutationRequestVersionResolution { Request = updatedRequest, Outcome = MutationRequestVersionResolutionOutcome.RequiresRenewedApproval, - ExpectedStateVersion = request.ExpectedStateVersion, - CurrentStateVersion = currentStateVersion, + ExpectedStateVersion = evaluation.ExpectedStateVersion, + CurrentStateVersion = evaluation.CurrentStateVersion, IsStale = true }; } @@ -109,58 +109,31 @@ request with /// public static MutationRequestVersionResolution BuildRevalidationRequired( MutationRequest request, - string currentStateVersion, + MutationRequestVersionEvaluation evaluation, MutationContext resolutionContext) { var decision = MutationRequestDecision.Create( MutationRequestDecisionType.RevalidationRequired, resolutionContext, - reason: BuildStaleReason(request.ExpectedStateVersion!, currentStateVersion), - metadata: CreateVersionMetadata(request.ExpectedStateVersion, currentStateVersion)); + reason: MutationRequestVersionResolutionState.BuildStaleReason( + evaluation.ExpectedStateVersion!, + evaluation.CurrentStateVersion), + metadata: MutationRequestVersionResolutionState.CreateVersionMetadata( + evaluation.ExpectedStateVersion, + evaluation.CurrentStateVersion)); - var updatedRequest = AppendDecision( - request with - { - Status = MutationRequestStatus.Approved, - PendingReason = null, - ExpectedStateVersion = currentStateVersion, - UpdatedAt = decision.Timestamp - }, + var updatedRequest = MutationRequestVersionResolutionState.ApplyRevalidationRequired( + request, + evaluation.CurrentStateVersion, decision); return new MutationRequestVersionResolution { Request = updatedRequest, Outcome = MutationRequestVersionResolutionOutcome.RevalidateOnLatestState, - ExpectedStateVersion = request.ExpectedStateVersion, - CurrentStateVersion = currentStateVersion, + ExpectedStateVersion = evaluation.ExpectedStateVersion, + CurrentStateVersion = evaluation.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/MutationRequestVersionResolutionManager.cs b/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionManager.cs new file mode 100644 index 0000000..b7d48cd --- /dev/null +++ b/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionManager.cs @@ -0,0 +1,54 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions; +using ModularityKit.Mutator.Governance.Abstractions.Resolution; +using ModularityKit.Mutator.Governance.Abstractions.Storage; + +namespace ModularityKit.Mutator.Governance.Runtime.Resolution; + +/// +/// Resolves governed requests against the current state version and persists the resulting resolution outcome. +/// +public sealed class MutationRequestVersionResolutionManager( + IMutationRequestStore requestStore, + IMutationRequestVersionResolver versionResolver) : IMutationRequestVersionResolutionManager +{ + private readonly IMutationRequestStore _requestStore = requestStore ?? throw new ArgumentNullException(nameof(requestStore)); + private readonly IMutationRequestVersionResolver _versionResolver = versionResolver ?? throw new ArgumentNullException(nameof(versionResolver)); + + /// + /// Loads a persisted request, resolves it using version-aware governance semantics, and stores the resulting request revision. + /// + public async Task ResolveAndStore( + string requestId, + string currentStateVersion, + MutationContext resolutionContext, + VersionedRequestResolutionStrategy strategy, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(requestId)) + throw new ArgumentException("Request ID is required.", nameof(requestId)); + + var request = await _requestStore.Get(requestId, cancellationToken).ConfigureAwait(false); + + if (request is null) + throw new MutationRequestNotFoundException(requestId); + + var resolution = _versionResolver.Resolve( + request, + currentStateVersion, + resolutionContext, + strategy); + + var persistedRequest = await _requestStore + .TryStore(resolution.Request, request.Revision, cancellationToken) + .ConfigureAwait(false); + + if (persistedRequest is null) + throw new MutationRequestConcurrencyException(request.RequestId, request.Revision); + + return resolution with + { + Request = persistedRequest + }; + } +} diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionState.cs b/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionState.cs new file mode 100644 index 0000000..25414f1 --- /dev/null +++ b/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionState.cs @@ -0,0 +1,115 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Requests; + +namespace ModularityKit.Mutator.Governance.Runtime.Resolution; + +/// +/// Provides shared state transformations and metadata helpers for version-aware governance resolution. +/// +internal static class MutationRequestVersionResolutionState +{ + /// + /// Appends a decision to the request decision history. + /// + public static MutationRequest AppendDecision( + MutationRequest request, + MutationRequestDecision decision) + { + return request with + { + Decisions = [.. request.Decisions, decision] + }; + } + + /// + /// Applies the rejected-as-stale state transition. + /// + public static MutationRequest ApplyRejectedAsStale( + MutationRequest request, + string currentStateVersion, + MutationRequestDecision decision) + { + return AppendDecision( + request with + { + Status = MutationRequestStatus.Rejected, + PendingReason = null, + UpdatedAt = decision.Timestamp + }, + decision); + } + + /// + /// Applies the renewed-approval-required state transition. + /// + public static MutationRequest ApplyRenewedApprovalRequired( + MutationRequest request, + string currentStateVersion, + MutationRequestDecision decision) + { + return AppendDecision( + request with + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval, + ExpectedStateVersion = currentStateVersion, + UpdatedAt = decision.Timestamp + }, + decision); + } + + /// + /// Applies the revalidation-required state transition. + /// + public static MutationRequest ApplyRevalidationRequired( + MutationRequest request, + string currentStateVersion, + MutationRequestDecision decision) + { + return AppendDecision( + request with + { + Status = MutationRequestStatus.Approved, + PendingReason = null, + ExpectedStateVersion = currentStateVersion, + UpdatedAt = decision.Timestamp + }, + decision); + } + + /// + /// Builds metadata describing the expected and current state versions used during resolution. + /// + public static IReadOnlyDictionary CreateVersionMetadata( + string? expectedStateVersion, + string currentStateVersion) + { + return new Dictionary + { + ["ExpectedStateVersion"] = expectedStateVersion ?? string.Empty, + ["CurrentStateVersion"] = currentStateVersion + }; + } + + /// + /// Builds the success reason for a request whose expected and current versions match. + /// + public static string BuildValidatedReason( + string? expectedStateVersion, + string currentStateVersion) + { + return string.IsNullOrWhiteSpace(expectedStateVersion) + ? "No expected state version was provided. Request can proceed." + : $"State version '{currentStateVersion}' matches the expected version."; + } + + /// + /// Builds the stale-version explanation used by stale resolution decisions. + /// + public static string BuildStaleReason( + string expectedStateVersion, + string currentStateVersion) + { + return $"Request expected state version '{expectedStateVersion}' but current version is '{currentStateVersion}'."; + } +} diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs b/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs index afcd96d..5a8d066 100644 --- a/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs +++ b/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs @@ -19,33 +19,27 @@ public MutationRequestVersionResolution Resolve( ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(resolutionContext); - if (string.IsNullOrWhiteSpace(currentStateVersion)) - throw new ArgumentException("Current state version is required.", nameof(currentStateVersion)); + var evaluation = MutationRequestVersionEvaluator.Evaluate(request, currentStateVersion); - var expectedStateVersion = request.ExpectedStateVersion; - var isStale = !string.IsNullOrWhiteSpace(expectedStateVersion) && - !string.Equals(expectedStateVersion, currentStateVersion, StringComparison.Ordinal); - - if (!isStale) + if (!evaluation.IsStale) return MutationRequestVersionResolutionFactory.BuildValidated( request, - expectedStateVersion, - currentStateVersion, + evaluation, resolutionContext); return strategy switch { VersionedRequestResolutionStrategy.RejectStale => MutationRequestVersionResolutionFactory.BuildRejectedAsStale( request, - currentStateVersion, + evaluation, resolutionContext), VersionedRequestResolutionStrategy.RequireRenewedApproval => MutationRequestVersionResolutionFactory.BuildRenewedApprovalRequired( request, - currentStateVersion, + evaluation, resolutionContext), VersionedRequestResolutionStrategy.RevalidateOnLatestState => MutationRequestVersionResolutionFactory.BuildRevalidationRequired( request, - currentStateVersion, + evaluation, resolutionContext), _ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown stale-resolution strategy.") };