diff --git a/Examples/Governance/VersionedResolution/README.md b/Examples/Governance/VersionedResolution/README.md index 3619222..c0849b5 100644 --- a/Examples/Governance/VersionedResolution/README.md +++ b/Examples/Governance/VersionedResolution/README.md @@ -12,6 +12,7 @@ It is the direct runnable example for the semantics introduced around `ExpectedS - resolving stale requests with `RevalidateOnLatestState` - persisting a resolved outcome through `MutationRequestVersionResolutionManager` - inspecting the resulting lifecycle state and appended decision history +- observing that revalidation is represented as `Pending` with `PendingMutationReason.Revalidation` ## Key files @@ -67,6 +68,7 @@ The sample prints one block per resolution strategy and one persisted-resolution - selected outcome - whether the request was stale - resulting request status +- the revalidation pending reason when the latest-state branch is selected - updated expected version - last decision recorded during resolution - persisted request revision for the runtime path diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs index aa3030c..52d6939 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs @@ -143,6 +143,48 @@ public async Task ExecuteApproved_requires_renewed_approval_before_execution_whe Assert.Equal(MutationRequestVersionResolutionOutcome.RequiresRenewedApproval, result.Resolution.Outcome); } + [Fact] + public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_when_strategy_demands_it() + { + var services = new ServiceCollection(); + services.AddMutators(MutationEngineOptions.Strict); + await using var provider = services.BuildServiceProvider(); + + var engine = provider.GetRequiredService(); + var requestStore = new InMemoryMutationRequestStore(); + var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); + var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); + + var request = await requestStore.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10")); + var mutation = new PromoteRoleMutation( + MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), + nextVersion: "v16"); + var state = RoleState.Create("tenant-42:roles", role: "Reader", version: "v15"); + + var result = await executionManager.ExecuteApproved( + request.RequestId, + mutation, + state, + currentStateVersion: state.Version, + resultingStateVersionProvider: updated => updated.Version, + governanceContext: MutationContext.Service("governance-runtime", "Revalidate and execute"), + strategy: VersionedRequestResolutionStrategy.RevalidateOnLatestState); + + Assert.True(result.WasExecuted); + Assert.NotNull(result.MutationResult); + Assert.Equal(MutationRequestVersionResolutionOutcome.RevalidateOnLatestState, result.Resolution.Outcome); + Assert.Equal(MutationRequestStatus.Executed, result.Request.Status); + Assert.Equal("v16", result.ResultingStateVersion); + Assert.Equal("v16", result.Request.ResultingStateVersion); + Assert.Equal("v16", result.Request.ExpectedStateVersion); + Assert.Equal( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), + result.Request.Decisions[^1].Type); + Assert.Contains( + result.Request.Decisions, + decision => decision.Type == MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RevalidationRequired)); + } + private sealed record RoleState(string StateId, string Role, string Version) { public static RoleState Create(string stateId, string role, string version) => new(stateId, role, version); diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs index da0844c..68de9b9 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs @@ -3,6 +3,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; using ModularityKit.Mutator.Governance.Runtime.Resolution.Execution; using ModularityKit.Mutator.Governance.Runtime.Storage; @@ -63,6 +64,35 @@ public async Task ResolveAndStore_persists_decision_history_and_state() Assert.Equal(loaded, resolution.Request); } + [Fact] + public async Task ResolveAndStore_revalidation_marks_request_pending_for_revalidation() + { + 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", "Revalidate request"), + strategy: VersionedRequestResolutionStrategy.RevalidateOnLatestState); + + var loaded = await store.Get(request.RequestId); + + Assert.NotNull(loaded); + Assert.Equal(3, loaded.Decisions.Count); + Assert.Equal( + MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RevalidationRequired), + loaded.Decisions[^1].Type); + Assert.Equal(MutationRequestStatus.Pending, loaded.Status); + Assert.Equal(PendingMutationReason.Revalidation, loaded.PendingReason); + Assert.Equal("v15", loaded.ExpectedStateVersion); + Assert.Equal(1, loaded.Revision); + Assert.Equal(MutationRequestVersionResolutionOutcome.RevalidateOnLatestState, resolution.Outcome); + Assert.Equal(loaded, resolution.Request); + } + [Fact] public async Task ResolveAndStore_throws_not_found_for_missing_request() { diff --git a/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirementStatus.cs b/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirementStatus.cs index c28250d..40eaf44 100644 --- a/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirementStatus.cs +++ b/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirementStatus.cs @@ -5,9 +5,24 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Approval.Model; /// public enum MutationApprovalRequirementStatus { + /// + /// The approval requirement is still waiting for a decision. + /// Pending = 0, + /// + /// The approval requirement has been approved. + /// Approved = 1, + /// + /// The approval requirement has been rejected. + /// Rejected = 2, + /// + /// The approval requirement has been satisfied by quorum or equivalent policy. + /// Satisfied = 3, + /// + /// The approval requirement expired before it was resolved. + /// Expired = 4 } diff --git a/src/Governance/Abstractions/Lifecycle/Model/MutationRequestStatus.cs b/src/Governance/Abstractions/Lifecycle/Model/MutationRequestStatus.cs index 62b3ed2..4f78c66 100644 --- a/src/Governance/Abstractions/Lifecycle/Model/MutationRequestStatus.cs +++ b/src/Governance/Abstractions/Lifecycle/Model/MutationRequestStatus.cs @@ -5,12 +5,36 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; /// public enum MutationRequestStatus { + /// + /// The request has been created but not yet processed. + /// Created = 0, + /// + /// The request is waiting for an external governance condition. + /// Pending = 1, + /// + /// The request has been approved and may proceed to execution. + /// Approved = 2, + /// + /// The request has been rejected and will not proceed. + /// Rejected = 3, + /// + /// The request has been canceled by an explicit action. + /// Canceled = 4, + /// + /// The request expired before it could be completed. + /// Expired = 5, + /// + /// The request has been superseded by another request. + /// Superseded = 6, + /// + /// The request has been executed successfully. + /// Executed = 7 } diff --git a/src/Governance/Abstractions/Lifecycle/Model/PendingMutationReason.cs b/src/Governance/Abstractions/Lifecycle/Model/PendingMutationReason.cs index 9cb767b..adc01a2 100644 --- a/src/Governance/Abstractions/Lifecycle/Model/PendingMutationReason.cs +++ b/src/Governance/Abstractions/Lifecycle/Model/PendingMutationReason.cs @@ -5,10 +5,38 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; /// public enum PendingMutationReason { + /// + /// The request is waiting for approval. + /// Approval = 0, + + /// + /// The request is waiting for an external check or integration response. + /// ExternalCheck = 1, + + /// + /// The request is waiting for a scheduled execution window. + /// Schedule = 2, + + /// + /// The request is waiting for a dependency to become ready. + /// Dependency = 3, + + /// + /// The request is waiting because of quota constraints. + /// Quota = 4, - ManualReview = 5 + + /// + /// The request is waiting for manual review. + /// + ManualReview = 5, + + /// + /// The request is pending because it must be revalidated against the latest state before execution. + /// + Revalidation = 6 } diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs index 1fe32e4..6473b8f 100644 --- a/src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs @@ -5,9 +5,24 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; /// public enum MutationRequestApprovalDecisionType { + /// + /// The approval requirement was requested. + /// Requested = 0, + /// + /// The approval requirement was granted. + /// Granted = 1, + /// + /// The approval requirement was rejected. + /// Rejected = 2, + /// + /// The approval quorum for a group was satisfied. + /// QuorumSatisfied = 3, + /// + /// The approval requirement expired. + /// Expired = 4 } diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionCategory.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionCategory.cs index 8992837..2447868 100644 --- a/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionCategory.cs +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionCategory.cs @@ -5,7 +5,16 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; /// public enum MutationRequestDecisionCategory { + /// + /// Lifecycle decisions describe request state transitions. + /// Lifecycle = 0, + /// + /// Approval decisions describe request-level approval processing. + /// Approval = 1, + /// + /// Version-resolution decisions describe stale and version-aware handling. + /// VersionResolution = 2 } diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs index 5d35621..337f7c4 100644 --- a/src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs @@ -5,12 +5,36 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; /// public enum MutationRequestLifecycleDecisionType { + /// + /// The request was submitted into the governance system. + /// Submitted = 0, + /// + /// The request entered a pending lifecycle state. + /// Pending = 1, + /// + /// The request was approved. + /// Approved = 2, + /// + /// The request was rejected. + /// Rejected = 3, + /// + /// The request was canceled. + /// Canceled = 4, + /// + /// The request expired before completion. + /// Expired = 5, + /// + /// The request was superseded by another request. + /// Superseded = 6, + /// + /// The request executed successfully. + /// Executed = 7 } diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestVersionResolutionDecisionType.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestVersionResolutionDecisionType.cs index 5e501cf..a131096 100644 --- a/src/Governance/Abstractions/Requests/Decisions/MutationRequestVersionResolutionDecisionType.cs +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestVersionResolutionDecisionType.cs @@ -5,8 +5,20 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; /// public enum MutationRequestVersionResolutionDecisionType { + /// + /// The request version matched the current state version. + /// Validated = 0, + /// + /// The request must be revalidated against the latest state. + /// RevalidationRequired = 1, + /// + /// The request must obtain renewed approval before proceeding. + /// RenewedApprovalRequired = 2, + /// + /// The request was rejected because it was stale. + /// RejectedAsStale = 3 } diff --git a/src/Governance/Abstractions/Resolution/Model/MutationRequestVersionResolutionOutcome.cs b/src/Governance/Abstractions/Resolution/Model/MutationRequestVersionResolutionOutcome.cs index a84fe8a..73d2c5c 100644 --- a/src/Governance/Abstractions/Resolution/Model/MutationRequestVersionResolutionOutcome.cs +++ b/src/Governance/Abstractions/Resolution/Model/MutationRequestVersionResolutionOutcome.cs @@ -5,8 +5,20 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; /// public enum MutationRequestVersionResolutionOutcome { + /// + /// The request can be executed with its approved version. + /// ExecuteApprovedVersion = 0, + /// + /// The request should be revalidated on the latest state. + /// RevalidateOnLatestState = 1, + /// + /// The request was rejected as stale. + /// RejectedAsStale = 2, + /// + /// The request requires renewed approval. + /// RequiresRenewedApproval = 3 } diff --git a/src/Governance/Abstractions/Resolution/Strategies/VersionedRequestResolutionStrategy.cs b/src/Governance/Abstractions/Resolution/Strategies/VersionedRequestResolutionStrategy.cs index 500283f..63cc6b5 100644 --- a/src/Governance/Abstractions/Resolution/Strategies/VersionedRequestResolutionStrategy.cs +++ b/src/Governance/Abstractions/Resolution/Strategies/VersionedRequestResolutionStrategy.cs @@ -5,7 +5,16 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; /// public enum VersionedRequestResolutionStrategy { + /// + /// Reject the request if the observed state version differs from the expected version. + /// RejectStale = 0, + /// + /// Send the request back through approval when the state has drifted. + /// RequireRenewedApproval = 1, + /// + /// Revalidate the request against the latest state before execution. + /// RevalidateOnLatestState = 2 } diff --git a/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs index 95b425e..95130f3 100644 --- a/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs +++ b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs @@ -71,8 +71,8 @@ public static MutationRequest ApplyRevalidationRequired( return AppendDecision( request with { - Status = MutationRequestStatus.Approved, - PendingReason = null, + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Revalidation, ExpectedStateVersion = currentStateVersion, UpdatedAt = decision.Timestamp },