From e81234dd93a5b49db2f98eda576b07ce9280117e Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 24 Jun 2026 02:13:14 +0200 Subject: [PATCH 1/2] Feat: Harden governance approval workflow --- .../GovernanceApprovalWorkflowScenario.cs | 89 ++++- .../MutationRequestApprovalWorkflowTests.cs | 346 ++++++++++++++---- ...IMutationRequestApprovalWorkflowManager.cs | 10 + .../MutationApprovalRequirementMapper.cs | 94 ++++- .../Model/MutationApprovalRejectionReason.cs | 27 ++ .../Model/MutationApprovalRequirement.cs | 30 ++ .../MutationApprovalRequirementStatus.cs | 4 +- ...dMutationApprovalConfigurationException.cs | 6 + ...dMutationApprovalWorkflowStateException.cs | 14 + ...tionApprovalRequirementExpiredException.cs | 26 ++ .../MutationRequestApprovalDecisionType.cs | 4 +- ...MutationRequestApprovalDecisionExecutor.cs | 190 ++++++++++ ...tationRequestApprovalExpirationExecutor.cs | 82 +++++ .../MutationRequestApprovalWorkflowManager.cs | 195 ++-------- .../MutationRequestApprovalPersistence.cs | 30 ++ .../MutationRequestApprovalWorkflowState.cs | 166 ++++++++- ...utationRequestApprovalWorkflowValidator.cs | 73 ++++ 17 files changed, 1135 insertions(+), 251 deletions(-) create mode 100644 src/Governance/Abstractions/Approval/Model/MutationApprovalRejectionReason.cs create mode 100644 src/Governance/Abstractions/Exceptions/Approval/InvalidMutationApprovalConfigurationException.cs create mode 100644 src/Governance/Abstractions/Exceptions/Approval/InvalidMutationApprovalWorkflowStateException.cs create mode 100644 src/Governance/Abstractions/Exceptions/Approval/MutationApprovalRequirementExpiredException.cs create mode 100644 src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs create mode 100644 src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs create mode 100644 src/Governance/Runtime/Approval/Persistence/MutationRequestApprovalPersistence.cs create mode 100644 src/Governance/Runtime/Approval/Validation/MutationRequestApprovalWorkflowValidator.cs diff --git a/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs b/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs index 05d8b52..abdef49 100644 --- a/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs +++ b/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs @@ -1,6 +1,7 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Runtime.Approval.Execution; @@ -27,7 +28,7 @@ public static async Task Run() MutationContext.User("alice", "Alice", "Manager approved")); PrintRequest(afterAlice); - PrintSection("Approve Step 1 - Second Actor"); + PrintSection("Approve Step 2 - Quorum Approval 1/2"); var bobApproval = afterAlice.ApprovalRequirements.Single(requirement => requirement.ApproverId == "bob"); var afterBob = await manager.ApproveRequirement( request.RequestId, @@ -35,13 +36,36 @@ public static async Task Run() MutationContext.User("bob", "Bob", "Security approved")); PrintRequest(afterBob); - PrintSection("Approve Step 2"); + PrintSection("Approve Step 2 - Quorum Approval 2/2"); var carolApproval = afterBob.ApprovalRequirements.Single(requirement => requirement.ApproverId == "carol"); var afterCarol = await manager.ApproveRequirement( request.RequestId, carolApproval.ApprovalId, MutationContext.User("carol", "Carol", "Finance approved")); PrintRequest(afterCarol); + + PrintSection("Approve Step 3 - Role Target"); + var financeApproval = afterCarol.ApprovalRequirements.Single(requirement => requirement.ApproverRole == "finance-approver"); + var afterFinance = await manager.ApproveRequirement( + request.RequestId, + financeApproval.ApprovalId, + MutationContext.User("frank", "Frank", "Finance role approved") with + { + Metadata = new Dictionary + { + ["ActorRoles"] = new[] { "finance-approver" } + } + }); + PrintRequest(afterFinance); + + PrintSection("Expire A Separate Pending Approval Request"); + var expiringRequest = await store.Create(CreateExpiringApprovalRequest()); + var expired = await manager.ExpirePendingApprovals( + DateTimeOffset.UtcNow, + MutationContext.Service("approval-timeout-monitor", "Expire stale approvals")); + + var expiredRequest = expired.Single(candidate => candidate.RequestId == expiringRequest.RequestId); + PrintRequest(expiredRequest); } private static MutationRequest CreateApprovalRequest() @@ -63,22 +87,24 @@ private static MutationRequest CreateApprovalRequest() new PolicyRequirement { Type = "Approval", - Description = "Security review", + Description = "Security quorum", Data = new { - Approver = "bob", - StepOrder = 1, + Approvers = new[] { "bob", "carol", "dave" }, + StepOrder = 2, + ApprovalGroupId = "security-quorum", + Quorum = 2, Reason = "Security sign-off" } }, new PolicyRequirement { Type = "Approval", - Description = "Finance review", + Description = "Finance role review", Data = new { - Approver = "carol", - StepOrder = 2, + ApproverRole = "finance-approver", + StepOrder = 3, Reason = "Budget sign-off" } } @@ -86,6 +112,37 @@ private static MutationRequest CreateApprovalRequest() expectedStateVersion: "v10"); } + private static MutationRequest CreateExpiringApprovalRequest() + { + return MutationRequestFactory.PendingApproval( + stateId: "tenant-42:deploy", + stateType: "DeploymentState", + mutationType: "ApproveDeploymentMutation", + intent: new MutationIntent + { + OperationName = "ApproveDeployment", + Category = "Operations", + Description = "Approval request that will expire" + }, + context: MutationContext.User("requester", "Requester", "Need emergency deployment approval"), + requirements: + [ + new PolicyRequirement + { + Type = "Approval", + Description = "Operations group approval", + Data = new + { + ApproverGroup = "ops-oncall", + StepOrder = 1, + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1), + Reason = "Operational readiness" + } + } + ], + expectedStateVersion: "v11"); + } + private static void PrintSection(string title) { Console.WriteLine(); @@ -102,11 +159,25 @@ private static void PrintRequest(MutationRequest request) foreach (var requirement in request.ApprovalRequirements.OrderBy(requirement => requirement.StepOrder).ThenBy(requirement => requirement.ApproverId)) { Console.WriteLine( - $" - Step {requirement.StepOrder}: {requirement.ApproverId} => {requirement.Status}"); + $" - Step {requirement.StepOrder}: {DescribeTarget(requirement)} => {requirement.Status} (group: {requirement.ApprovalGroupId ?? "-" }, quorum: {requirement.RequiredApprovals}, expires: {requirement.ExpiresAt?.ToString("O") ?? "-"})"); } var lastDecision = request.Decisions[^1]; Console.WriteLine($"Last decision: {lastDecision.Type} by {lastDecision.Context.ActorId ?? "system"}"); Console.WriteLine($"Reason: {lastDecision.Reason ?? "-"}"); } + + private static string DescribeTarget(MutationApprovalRequirement requirement) + { + if (!string.IsNullOrWhiteSpace(requirement.ApproverId)) + return requirement.ApproverId; + + if (!string.IsNullOrWhiteSpace(requirement.ApproverRole)) + return $"role:{requirement.ApproverRole}"; + + if (!string.IsNullOrWhiteSpace(requirement.ApproverGroup)) + return $"group:{requirement.ApproverGroup}"; + + return "unknown"; + } } diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs index a3e9453..fd29e17 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs @@ -16,8 +16,10 @@ namespace ModularityKit.Mutator.Governance.Tests.Approval; public sealed class MutationRequestApprovalWorkflowTests { [Fact] - public void PendingApproval_maps_policy_requirements_into_visible_request_approval_requirements() + public void PendingApproval_maps_id_role_group_quorum_and_expiration_targets() { + var expiresAt = DateTimeOffset.UtcNow.AddHours(1); + var request = MutationRequestFactory.PendingApproval( stateId: "tenant-42:roles", stateType: "IamRoleState", @@ -30,12 +32,37 @@ public void PendingApproval_maps_policy_requirements_into_visible_request_approv new PolicyRequirement { Type = "Approval", - Description = "Security and finance approval", + Description = "Security quorum", Data = new { - Approvers = new[] { "bob", "carol" }, + Approvers = new[] { "bob", "carol", "dave" }, StepOrder = 2, - Reason = "Cross-functional sign-off" + ApprovalGroupId = "security-quorum", + Quorum = 2, + ExpiresAt = expiresAt, + Reason = "Security sign-off" + } + }, + new PolicyRequirement + { + Type = "Approval", + Description = "Finance role approval", + Data = new + { + ApproverRole = "finance-approver", + StepOrder = 3, + Reason = "Finance sign-off" + } + }, + new PolicyRequirement + { + Type = "Approval", + Description = "Operations group approval", + Data = new + { + ApproverGroup = "ops-oncall", + StepOrder = 4, + Reason = "Operational readiness" } } ], @@ -43,42 +70,34 @@ public void PendingApproval_maps_policy_requirements_into_visible_request_approv Assert.Equal(MutationRequestStatus.Pending, request.Status); Assert.Equal(PendingMutationReason.Approval, request.PendingReason); - Assert.Equal(3, request.ApprovalRequirements.Count); - Assert.Collection( - request.ApprovalRequirements.OrderBy(requirement => requirement.StepOrder).ThenBy(requirement => requirement.ApproverId), - first => - { - Assert.Equal("alice", first.ApproverId); - Assert.Equal(1, first.StepOrder); - Assert.Equal(MutationApprovalRequirementStatus.Pending, first.Status); - }, - second => - { - Assert.Equal("bob", second.ApproverId); - Assert.Equal(2, second.StepOrder); - }, - third => - { - Assert.Equal("carol", third.ApproverId); - Assert.Equal(2, third.StepOrder); - }); + Assert.Equal(6, request.ApprovalRequirements.Count); + + var securityApprovals = request.ApprovalRequirements + .Where(requirement => requirement.ApprovalGroupId == "security-quorum") + .OrderBy(requirement => requirement.ApproverId) + .ToList(); + + Assert.Equal(3, securityApprovals.Count); + Assert.All(securityApprovals, requirement => + { + Assert.Equal(2, requirement.RequiredApprovals); + Assert.Equal(expiresAt, requirement.ExpiresAt); + Assert.Equal(2, requirement.StepOrder); + }); + + var financeApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverRole == "finance-approver"); + Assert.Equal(3, financeApproval.StepOrder); + + var operationsApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverGroup == "ops-oncall"); + Assert.Equal(4, operationsApproval.StepOrder); } [Fact] - public async Task ApproveRequirement_enforces_step_order_and_marks_request_approved_after_final_approval() + public async Task ApproveRequirement_supports_quorum_groups_and_marks_remaining_group_requirements_satisfied() { var store = new InMemoryMutationRequestStore(); var manager = new MutationRequestApprovalWorkflowManager(store); - var request = await store.Create(CreateMultiStepApprovalRequest()); - - var stepTwoApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "carol"); - var invalidStep = await Assert.ThrowsAsync(() => - manager.ApproveRequirement( - request.RequestId, - stepTwoApproval.ApprovalId, - MutationContext.User("carol", "Carol", "Approve too early"))); - - Assert.Equal(stepTwoApproval.ApprovalId, invalidStep.ApprovalId); + var request = await store.Create(CreateQuorumApprovalRequest()); var aliceApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice"); var afterAlice = await manager.ApproveRequirement( @@ -87,7 +106,6 @@ public async Task ApproveRequirement_enforces_step_order_and_marks_request_appro MutationContext.User("alice", "Alice", "Manager approved")); Assert.Equal(MutationRequestStatus.Pending, afterAlice.Status); - Assert.Equal(MutationApprovalRequirementStatus.Approved, afterAlice.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice").Status); var bobApproval = afterAlice.ApprovalRequirements.Single(requirement => requirement.ApproverId == "bob"); var afterBob = await manager.ApproveRequirement( @@ -97,50 +115,148 @@ public async Task ApproveRequirement_enforces_step_order_and_marks_request_appro Assert.Equal(MutationRequestStatus.Pending, afterBob.Status); - var finalCarolApproval = afterBob.ApprovalRequirements.Single(requirement => requirement.ApproverId == "carol"); + var carolApproval = afterBob.ApprovalRequirements.Single(requirement => requirement.ApproverId == "carol"); var afterCarol = await manager.ApproveRequirement( request.RequestId, - finalCarolApproval.ApprovalId, - MutationContext.User("carol", "Carol", "Finance approved")); - - Assert.Equal(MutationRequestStatus.Approved, afterCarol.Status); - Assert.Null(afterCarol.PendingReason); - Assert.All(afterCarol.ApprovalRequirements, requirement => Assert.Equal(MutationApprovalRequirementStatus.Approved, requirement.Status)); - Assert.Equal( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), - afterCarol.Decisions[^1].Type); - Assert.Contains( - afterCarol.Decisions, - decision => decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted)); + carolApproval.ApprovalId, + MutationContext.User("carol", "Carol", "Security approved")); + + Assert.Equal(MutationRequestStatus.Pending, afterCarol.Status); + Assert.Contains(afterCarol.Decisions, decision => + decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.QuorumSatisfied)); + + var securityGroup = afterCarol.ApprovalRequirements + .Where(requirement => requirement.ApprovalGroupId == "security-quorum") + .ToList(); + + Assert.Equal(2, securityGroup.Count(requirement => requirement.Status == MutationApprovalRequirementStatus.Approved)); + Assert.Equal(1, securityGroup.Count(requirement => requirement.Status == MutationApprovalRequirementStatus.Satisfied)); + + var financeApproval = afterCarol.ApprovalRequirements.Single(requirement => requirement.ApproverRole == "finance-approver"); + var afterFinance = await manager.ApproveRequirement( + request.RequestId, + financeApproval.ApprovalId, + CreateRoleContext("frank", "Frank", "Finance approved", "finance-approver")); + + Assert.Equal(MutationRequestStatus.Approved, afterFinance.Status); + Assert.Null(afterFinance.PendingReason); } [Fact] - public async Task RejectRequirement_marks_request_rejected_and_records_explicit_history() + public async Task ApproveRequirement_accepts_role_and_group_targeting() { var store = new InMemoryMutationRequestStore(); var manager = new MutationRequestApprovalWorkflowManager(store); - var request = await store.Create(CreateMultiStepApprovalRequest()); + var request = await store.Create(CreateRoleAndGroupApprovalRequest()); + + var roleApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverRole == "security-admin"); + var afterRole = await manager.ApproveRequirement( + request.RequestId, + roleApproval.ApprovalId, + CreateRoleContext("sara", "Sara", "Security role approved", "security-admin")); + + Assert.Equal(MutationApprovalRequirementStatus.Approved, afterRole.ApprovalRequirements.Single(requirement => requirement.ApprovalId == roleApproval.ApprovalId).Status); + Assert.Equal(MutationRequestStatus.Pending, afterRole.Status); + + var groupApproval = afterRole.ApprovalRequirements.Single(requirement => requirement.ApproverGroup == "ops-oncall"); + var afterGroup = await manager.ApproveRequirement( + request.RequestId, + groupApproval.ApprovalId, + CreateGroupContext("oliver", "Oliver", "Operations approved", "ops-oncall")); + + Assert.Equal(MutationRequestStatus.Approved, afterGroup.Status); + } + + [Fact] + public async Task RejectRequirement_persists_structured_rejection_reason() + { + var store = new InMemoryMutationRequestStore(); + var manager = new MutationRequestApprovalWorkflowManager(store); + var request = await store.Create(CreateLinearApprovalRequest()); var aliceApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice"); + var rejection = new MutationApprovalRejectionReason + { + Code = "missing-justification", + Category = "policy", + Message = "Change request did not include business justification.", + Metadata = new Dictionary + { + ["TicketId"] = "CHG-42" + } + }; + var rejected = await manager.RejectRequirement( request.RequestId, aliceApproval.ApprovalId, MutationContext.User("alice", "Alice", "Manager rejected"), - reason: "Insufficient justification"); + rejection: rejection); + + var rejectedRequirement = rejected.ApprovalRequirements.Single(requirement => requirement.ApprovalId == aliceApproval.ApprovalId); Assert.Equal(MutationRequestStatus.Rejected, rejected.Status); - Assert.Null(rejected.PendingReason); - Assert.Equal(MutationApprovalRequirementStatus.Rejected, rejected.ApprovalRequirements.Single(requirement => requirement.ApprovalId == aliceApproval.ApprovalId).Status); - Assert.Equal( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected), - rejected.Decisions[^1].Type); - Assert.Contains( - rejected.Decisions, - decision => decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Rejected)); - Assert.Contains(rejected.Decisions, decision => decision.Reason == "Insufficient justification"); + Assert.Equal(MutationApprovalRequirementStatus.Rejected, rejectedRequirement.Status); + Assert.NotNull(rejectedRequirement.Rejection); + Assert.Equal("missing-justification", rejectedRequirement.Rejection!.Code); + Assert.Contains(rejected.Decisions, decision => + decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Rejected) && + Equals(decision.Metadata["RejectionCode"], "missing-justification")); + } + + [Fact] + public async Task ExpirePendingApprovals_rejects_requests_with_expired_approval_requirements() + { + var store = new InMemoryMutationRequestStore(); + var manager = new MutationRequestApprovalWorkflowManager(store); + var request = await store.Create(CreateExpiredApprovalRequest()); + + var expired = await manager.ExpirePendingApprovals( + DateTimeOffset.UtcNow, + MutationContext.Service("approval-timeout-monitor", "Expire stale approvals")); + + var expiredRequest = Assert.Single(expired); + + Assert.Equal(request.RequestId, expiredRequest.RequestId); + Assert.Equal(MutationRequestStatus.Rejected, expiredRequest.Status); + Assert.Contains(expiredRequest.ApprovalRequirements, requirement => requirement.Status == MutationApprovalRequirementStatus.Expired); + Assert.Contains(expiredRequest.Decisions, decision => + decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Expired)); + } + + [Fact] + public async Task ApproveRequirement_throws_domain_exception_when_requirement_is_expired() + { + var store = new InMemoryMutationRequestStore(); + var manager = new MutationRequestApprovalWorkflowManager(store); + var request = await store.Create(CreateExpiredApprovalRequest()); + var expiredApproval = request.ApprovalRequirements.Single(); + + var exception = await Assert.ThrowsAsync(() => + manager.ApproveRequirement( + request.RequestId, + expiredApproval.ApprovalId, + MutationContext.User("alice", "Alice", "Approve expired requirement"))); + + Assert.Equal(request.RequestId, exception.RequestId); + Assert.Equal(expiredApproval.ApprovalId, exception.ApprovalId); + } + + private static MutationRequest CreateLinearApprovalRequest() + { + return MutationRequestFactory.PendingApproval( + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + intent: CreateIntent(), + context: MutationContext.User("requester", "Requester", "Needs privileged access"), + requirements: + [ + PolicyRequirement.Approval("alice", "Manager approval") + ], + expectedStateVersion: "v10"); } - private static MutationRequest CreateMultiStepApprovalRequest() + private static MutationRequest CreateQuorumApprovalRequest() { return MutationRequestFactory.PendingApproval( stateId: "tenant-42:roles", @@ -154,29 +270,123 @@ private static MutationRequest CreateMultiStepApprovalRequest() new PolicyRequirement { Type = "Approval", - Description = "Security review", + Description = "Security quorum", Data = new { - Approver = "bob", - StepOrder = 1, + Approvers = new[] { "bob", "carol", "dave" }, + StepOrder = 2, + ApprovalGroupId = "security-quorum", + Quorum = 2, Reason = "Security sign-off" } }, new PolicyRequirement { Type = "Approval", - Description = "Finance review", + Description = "Finance role approval", Data = new { - Approver = "carol", - StepOrder = 2, - Reason = "Budget sign-off" + ApproverRole = "finance-approver", + StepOrder = 3, + Reason = "Finance sign-off" } } ], expectedStateVersion: "v10"); } + private static MutationRequest CreateRoleAndGroupApprovalRequest() + { + return MutationRequestFactory.PendingApproval( + stateId: "tenant-42:deploy", + stateType: "DeploymentState", + mutationType: "ApproveDeploymentMutation", + intent: CreateIntent(), + context: MutationContext.User("requester", "Requester", "Need deployment approval"), + requirements: + [ + new PolicyRequirement + { + Type = "Approval", + Description = "Security role approval", + Data = new + { + ApproverRole = "security-admin", + StepOrder = 1, + Reason = "Security review" + } + }, + new PolicyRequirement + { + Type = "Approval", + Description = "Operations group approval", + Data = new + { + ApproverGroup = "ops-oncall", + StepOrder = 2, + Reason = "Operational readiness" + } + } + ], + expectedStateVersion: "v7"); + } + + private static MutationRequest CreateExpiredApprovalRequest() + { + return MutationRequestFactory.PendingApproval( + stateId: "tenant-42:billing", + stateType: "BillingState", + mutationType: "IncreaseQuotaMutation", + intent: CreateIntent(), + context: MutationContext.User("requester", "Requester", "Need urgent quota increase"), + requirements: + [ + new PolicyRequirement + { + Type = "Approval", + Description = "Manager approval", + Data = new + { + Approver = "alice", + StepOrder = 1, + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5), + Reason = "Manager sign-off" + } + } + ], + expectedStateVersion: "v5"); + } + + private static MutationContext CreateRoleContext( + string actorId, + string actorName, + string reason, + params string[] roles) + { + return MutationContext.User(actorId, actorName, reason) with + { + Metadata = new Dictionary + { + ["ActorRoles"] = roles + } + }; + } + + private static MutationContext CreateGroupContext( + string actorId, + string actorName, + string reason, + params string[] groups) + { + return MutationContext.User(actorId, actorName, reason) with + { + Metadata = new Dictionary + { + ["ActorGroups"] = groups + } + }; + } + private static MutationIntent CreateIntent() { return new MutationIntent diff --git a/src/Governance/Abstractions/Approval/Contracts/IMutationRequestApprovalWorkflowManager.cs b/src/Governance/Abstractions/Approval/Contracts/IMutationRequestApprovalWorkflowManager.cs index b9bfdc2..7603f63 100644 --- a/src/Governance/Abstractions/Approval/Contracts/IMutationRequestApprovalWorkflowManager.cs +++ b/src/Governance/Abstractions/Approval/Contracts/IMutationRequestApprovalWorkflowManager.cs @@ -1,4 +1,5 @@ using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Abstractions.Approval.Contracts; @@ -27,6 +28,15 @@ Task RejectRequirement( string approvalId, MutationContext decisionContext, string? reason = null, + MutationApprovalRejectionReason? rejection = null, IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default); + + /// + /// Expires requests whose pending approval requirements have passed their approval-specific expiration time. + /// + Task> ExpirePendingApprovals( + DateTimeOffset now, + MutationContext decisionContext, + CancellationToken cancellationToken = default); } diff --git a/src/Governance/Abstractions/Approval/Mapping/MutationApprovalRequirementMapper.cs b/src/Governance/Abstractions/Approval/Mapping/MutationApprovalRequirementMapper.cs index f4bc7d7..d164fc4 100644 --- a/src/Governance/Abstractions/Approval/Mapping/MutationApprovalRequirementMapper.cs +++ b/src/Governance/Abstractions/Approval/Mapping/MutationApprovalRequirementMapper.cs @@ -1,5 +1,6 @@ using System.Collections; using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval; using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; namespace ModularityKit.Mutator.Governance.Abstractions.Approval.Mapping; @@ -35,6 +36,14 @@ private static IReadOnlyList ExtractApprovalDefinit var stepOrder = ReadIntProperty(requirement.Data, "StepOrder") ?? defaultStepOrder + 1; var approverName = ReadStringProperty(requirement.Data, "ApproverName"); var reason = ReadStringProperty(requirement.Data, "Reason"); + var approvalGroupId = ReadStringProperty(requirement.Data, "ApprovalGroupId") + ?? ReadStringProperty(requirement.Data, "GroupId"); + var approverRole = ReadStringProperty(requirement.Data, "ApproverRole"); + var approverGroup = ReadStringProperty(requirement.Data, "ApproverGroup"); + var requiredApprovals = ReadIntProperty(requirement.Data, "RequiredApprovals") + ?? ReadIntProperty(requirement.Data, "Quorum") + ?? 1; + var expiresAt = ReadDateTimeOffsetProperty(requirement.Data, "ExpiresAt"); var approvers = ReadStringSequenceProperty(requirement.Data, "Approvers"); if (approvers.Count == 0) @@ -44,11 +53,26 @@ private static IReadOnlyList ExtractApprovalDefinit approvers = [approver]; } - if (approvers.Count == 0) - throw new InvalidOperationException( - $"Approval requirement '{requirement.Description}' does not define an approver."); + var targetCount = approvers.Count + + (!string.IsNullOrWhiteSpace(approverRole) ? 1 : 0) + + (!string.IsNullOrWhiteSpace(approverGroup) ? 1 : 0); + + if (targetCount == 0) + throw new InvalidMutationApprovalConfigurationException( + $"Approval requirement '{requirement.Description}' does not define an approver, approver role, or approver group."); + + if (requiredApprovals <= 0) + throw new InvalidMutationApprovalConfigurationException( + $"Approval requirement '{requirement.Description}' must require at least one approval."); - return approvers + if (requiredApprovals > targetCount) + throw new InvalidMutationApprovalConfigurationException( + $"Approval requirement '{requirement.Description}' requires {requiredApprovals} approval(s) but only defines {targetCount} target(s)."); + + if (targetCount > 1 && string.IsNullOrWhiteSpace(approvalGroupId)) + approvalGroupId = $"approval-group-{defaultStepOrder + 1}-{defaultStepOrder}"; + + var mapped = approvers .Select(approverId => new MutationApprovalRequirement { Type = requirement.Type, @@ -56,6 +80,9 @@ private static IReadOnlyList ExtractApprovalDefinit ApproverId = approverId, ApproverName = approverName, StepOrder = stepOrder, + ApprovalGroupId = approvalGroupId, + RequiredApprovals = requiredApprovals, + ExpiresAt = expiresAt, Metadata = new Dictionary { ["RequirementDescription"] = requirement.Description, @@ -63,6 +90,48 @@ private static IReadOnlyList ExtractApprovalDefinit } }) .ToList(); + + if (!string.IsNullOrWhiteSpace(approverRole)) + { + mapped.Add(new MutationApprovalRequirement + { + Type = requirement.Type, + Description = requirement.Description, + ApproverRole = approverRole, + ApproverName = approverName, + StepOrder = stepOrder, + ApprovalGroupId = approvalGroupId, + RequiredApprovals = requiredApprovals, + ExpiresAt = expiresAt, + Metadata = new Dictionary + { + ["RequirementDescription"] = requirement.Description, + ["RequirementReason"] = reason ?? string.Empty + } + }); + } + + if (!string.IsNullOrWhiteSpace(approverGroup)) + { + mapped.Add(new MutationApprovalRequirement + { + Type = requirement.Type, + Description = requirement.Description, + ApproverGroup = approverGroup, + ApproverName = approverName, + StepOrder = stepOrder, + ApprovalGroupId = approvalGroupId, + RequiredApprovals = requiredApprovals, + ExpiresAt = expiresAt, + Metadata = new Dictionary + { + ["RequirementDescription"] = requirement.Description, + ["RequirementReason"] = reason ?? string.Empty + } + }); + } + + return mapped; } private static string? ReadStringProperty(object? source, string propertyName) @@ -91,6 +160,23 @@ private static IReadOnlyList ExtractApprovalDefinit }; } + private static DateTimeOffset? ReadDateTimeOffsetProperty(object? source, string propertyName) + { + if (source is null) + return null; + + var property = source.GetType().GetProperty(propertyName); + var value = property?.GetValue(source); + + return value switch + { + DateTimeOffset dto => dto, + DateTime dt => new DateTimeOffset(dt), + string text when DateTimeOffset.TryParse(text, out var parsed) => parsed, + _ => null + }; + } + private static List ReadStringSequenceProperty(object? source, string propertyName) { if (source is null) diff --git a/src/Governance/Abstractions/Approval/Model/MutationApprovalRejectionReason.cs b/src/Governance/Abstractions/Approval/Model/MutationApprovalRejectionReason.cs new file mode 100644 index 0000000..5e0d3ca --- /dev/null +++ b/src/Governance/Abstractions/Approval/Model/MutationApprovalRejectionReason.cs @@ -0,0 +1,27 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Approval.Model; + +/// +/// Represents a structured business reason for rejecting an approval requirement. +/// +public sealed record MutationApprovalRejectionReason +{ + /// + /// Stable machine-readable rejection code. + /// + public string Code { get; init; } = string.Empty; + + /// + /// Optional higher-level rejection category. + /// + public string? Category { get; init; } + + /// + /// Human-readable rejection message. + /// + public string Message { get; init; } = string.Empty; + + /// + /// Additional structured metadata attached to the rejection. + /// + public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); +} diff --git a/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirement.cs b/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirement.cs index d6b0111..24f8b3b 100644 --- a/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirement.cs +++ b/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirement.cs @@ -27,6 +27,16 @@ public sealed record MutationApprovalRequirement /// public string ApproverId { get; init; } = string.Empty; + /// + /// Optional role required to resolve this requirement. + /// + public string? ApproverRole { get; init; } + + /// + /// Optional group required to resolve this requirement. + /// + public string? ApproverGroup { get; init; } + /// /// Optional human-readable name of the approver. /// @@ -38,6 +48,21 @@ public sealed record MutationApprovalRequirement /// public int StepOrder { get; init; } = 1; + /// + /// Optional identifier used to group related approval requirements into one logical approval set. + /// + public string? ApprovalGroupId { get; init; } + + /// + /// Number of approvals required to satisfy this requirement group. + /// + public int RequiredApprovals { get; init; } = 1; + + /// + /// Optional expiration time for this approval requirement. + /// + public DateTimeOffset? ExpiresAt { get; init; } + /// /// Current state of the approval requirement. /// @@ -58,6 +83,11 @@ public sealed record MutationApprovalRequirement /// public string? DecisionReason { get; init; } + /// + /// Optional structured rejection reason recorded when the requirement is rejected. + /// + public MutationApprovalRejectionReason? Rejection { get; init; } + /// /// Additional approval metadata for integrations and audit trails. /// diff --git a/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirementStatus.cs b/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirementStatus.cs index b13e233..c28250d 100644 --- a/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirementStatus.cs +++ b/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirementStatus.cs @@ -7,5 +7,7 @@ public enum MutationApprovalRequirementStatus { Pending = 0, Approved = 1, - Rejected = 2 + Rejected = 2, + Satisfied = 3, + Expired = 4 } diff --git a/src/Governance/Abstractions/Exceptions/Approval/InvalidMutationApprovalConfigurationException.cs b/src/Governance/Abstractions/Exceptions/Approval/InvalidMutationApprovalConfigurationException.cs new file mode 100644 index 0000000..eb385a1 --- /dev/null +++ b/src/Governance/Abstractions/Exceptions/Approval/InvalidMutationApprovalConfigurationException.cs @@ -0,0 +1,6 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval; + +/// +/// Raised when approval requirements are configured in a way the governance runtime cannot execute safely. +/// +public sealed class InvalidMutationApprovalConfigurationException(string message) : InvalidOperationException(message); diff --git a/src/Governance/Abstractions/Exceptions/Approval/InvalidMutationApprovalWorkflowStateException.cs b/src/Governance/Abstractions/Exceptions/Approval/InvalidMutationApprovalWorkflowStateException.cs new file mode 100644 index 0000000..e7d7698 --- /dev/null +++ b/src/Governance/Abstractions/Exceptions/Approval/InvalidMutationApprovalWorkflowStateException.cs @@ -0,0 +1,14 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval; + +/// +/// Raised when approval actions are attempted against a request that is not in an approval workflow state. +/// +public sealed class InvalidMutationApprovalWorkflowStateException( + string requestId, + string message) : InvalidOperationException(message) +{ + /// + /// Request identifier against which the invalid approval workflow action was attempted. + /// + public string RequestId { get; } = requestId; +} diff --git a/src/Governance/Abstractions/Exceptions/Approval/MutationApprovalRequirementExpiredException.cs b/src/Governance/Abstractions/Exceptions/Approval/MutationApprovalRequirementExpiredException.cs new file mode 100644 index 0000000..5ba15ed --- /dev/null +++ b/src/Governance/Abstractions/Exceptions/Approval/MutationApprovalRequirementExpiredException.cs @@ -0,0 +1,26 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval; + +/// +/// Raised when a pending approval requirement has already expired. +/// +public sealed class MutationApprovalRequirementExpiredException( + string requestId, + string approvalId, + DateTimeOffset expiresAt) : InvalidOperationException( + $"Approval requirement '{approvalId}' on request '{requestId}' expired at '{expiresAt:O}'.") +{ + /// + /// Request identifier on which the expired approval requirement exists. + /// + public string RequestId { get; } = requestId; + + /// + /// Expired approval identifier. + /// + public string ApprovalId { get; } = approvalId; + + /// + /// Timestamp at which the approval requirement expired. + /// + public DateTimeOffset ExpiresAt { get; } = expiresAt; +} diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs index d3805fc..1fe32e4 100644 --- a/src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs @@ -7,5 +7,7 @@ public enum MutationRequestApprovalDecisionType { Requested = 0, Granted = 1, - Rejected = 2 + Rejected = 2, + QuorumSatisfied = 3, + Expired = 4 } diff --git a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs new file mode 100644 index 0000000..8c5db0c --- /dev/null +++ b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs @@ -0,0 +1,190 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; +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.Storage; +using ModularityKit.Mutator.Governance.Runtime.Approval.Persistence; +using ModularityKit.Mutator.Governance.Runtime.Approval.State; +using ModularityKit.Mutator.Governance.Runtime.Approval.Validation; + +namespace ModularityKit.Mutator.Governance.Runtime.Approval.Execution; + +/// +/// Applies approval and rejection decisions to governed requests and persists the resulting request state. +/// +internal sealed class MutationRequestApprovalDecisionExecutor(IMutationRequestStore requestStore) +{ + private readonly IMutationRequestStore _requestStore = requestStore ?? throw new ArgumentNullException(nameof(requestStore)); + private readonly MutationRequestApprovalPersistence _persistence = new(requestStore); + + /// + /// Applies single approval decision to request level approval requirement. + /// + public async Task ApplyDecision( + string requestId, + string approvalId, + MutationContext decisionContext, + string? reason, + MutationApprovalRejectionReason? rejection, + IReadOnlyDictionary? metadata, + Func applyResolution, + MutationRequestDecisionType decisionType, + bool finalizeApprovedRequest, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(requestId)) + throw new ArgumentException("Request ID is required.", nameof(requestId)); + + if (string.IsNullOrWhiteSpace(approvalId)) + throw new ArgumentException("Approval ID is required.", nameof(approvalId)); + + ArgumentNullException.ThrowIfNull(decisionContext); + ArgumentNullException.ThrowIfNull(applyResolution); + + var request = await GetRequired(requestId, cancellationToken).ConfigureAwait(false); + MutationRequestApprovalWorkflowValidator.ValidateWorkflowRequest(request); + + var approvalRequirement = request.ApprovalRequirements.FirstOrDefault(requirement => requirement.ApprovalId == approvalId); + if (approvalRequirement is null) + throw new MutationApprovalRequirementNotFoundException(request.RequestId, approvalId); + + MutationRequestApprovalWorkflowValidator.ValidateApprovalAction(request, approvalRequirement, decisionContext); + + var resolvedRequirement = applyResolution(approvalRequirement, decisionContext, reason, rejection); + var updatedRequirements = MutationRequestApprovalWorkflowState.Replace(request.ApprovalRequirements, resolvedRequirement); + updatedRequirements = MutationRequestApprovalWorkflowState.ApplyQuorumSatisfaction(updatedRequirements, resolvedRequirement, decisionContext); + + var decisions = BuildDecisionHistory( + request, + updatedRequirements, + resolvedRequirement, + decisionContext, + reason, + rejection, + metadata, + decisionType, + finalizeApprovedRequest); + + var updatedRequest = BuildUpdatedRequest( + request, + updatedRequirements, + decisions, + finalizeApprovedRequest); + + return await _persistence.Persist(request, updatedRequest, cancellationToken).ConfigureAwait(false); + } + + private static List BuildDecisionHistory( + MutationRequest request, + IReadOnlyList updatedRequirements, + MutationApprovalRequirement resolvedRequirement, + MutationContext decisionContext, + string? reason, + MutationApprovalRejectionReason? rejection, + IReadOnlyDictionary? metadata, + MutationRequestDecisionType decisionType, + bool finalizeApprovedRequest) + { + var decisions = new List(request.Decisions) + { + MutationRequestApprovalWorkflowState.CreateApprovalDecision( + decisionType, + resolvedRequirement, + decisionContext, + reason, + rejection, + metadata) + }; + + if (finalizeApprovedRequest && + !string.IsNullOrWhiteSpace(resolvedRequirement.ApprovalGroupId) && + updatedRequirements.Any(requirement => + requirement.StepOrder == resolvedRequirement.StepOrder && + string.Equals(requirement.ApprovalGroupId, resolvedRequirement.ApprovalGroupId, StringComparison.Ordinal) && + requirement.Status == MutationApprovalRequirementStatus.Satisfied)) + { + decisions.Add(MutationRequestDecision.Create( + MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.QuorumSatisfied), + decisionContext, + reason: $"Approval quorum satisfied for group '{resolvedRequirement.ApprovalGroupId}'.", + metadata: new Dictionary + { + ["ApprovalGroupId"] = resolvedRequirement.ApprovalGroupId, + ["RequiredApprovals"] = resolvedRequirement.RequiredApprovals + })); + } + + var isFullyApproved = updatedRequirements.All(requirement => + requirement.Status is MutationApprovalRequirementStatus.Approved or MutationApprovalRequirementStatus.Satisfied); + + if (finalizeApprovedRequest && isFullyApproved) + { + decisions.Add(MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + decisionContext, + reason: "All approval requirements were fulfilled.")); + } + else if (!finalizeApprovedRequest) + { + decisions.Add(MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected), + decisionContext, + reason: reason ?? rejection?.Message ?? decisionContext.Reason ?? "Request was rejected during approval workflow.")); + } + + return decisions; + } + + private static MutationRequest BuildUpdatedRequest( + MutationRequest request, + IReadOnlyList updatedRequirements, + IReadOnlyList decisions, + bool finalizeApprovedRequest) + { + var updatedRequest = request with + { + ApprovalRequirements = updatedRequirements + }; + + if (finalizeApprovedRequest) + { + var isFullyApproved = updatedRequirements.All(requirement => + requirement.Status is MutationApprovalRequirementStatus.Approved or MutationApprovalRequirementStatus.Satisfied); + + updatedRequest = updatedRequest with + { + Status = isFullyApproved ? MutationRequestStatus.Approved : MutationRequestStatus.Pending, + PendingReason = isFullyApproved ? null : PendingMutationReason.Approval + }; + } + else + { + updatedRequest = updatedRequest with + { + Status = MutationRequestStatus.Rejected, + PendingReason = null + }; + } + + return updatedRequest with + { + Decisions = decisions, + UpdatedAt = decisions[^1].Timestamp + }; + } + + 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/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs new file mode 100644 index 0000000..bd54bf7 --- /dev/null +++ b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs @@ -0,0 +1,82 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; +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.Storage; +using ModularityKit.Mutator.Governance.Runtime.Approval.Persistence; +using ModularityKit.Mutator.Governance.Runtime.Approval.State; + +namespace ModularityKit.Mutator.Governance.Runtime.Approval.Execution; + +/// +/// Applies approval specific expiration semantics to pending approval requests. +/// +internal sealed class MutationRequestApprovalExpirationExecutor(IMutationRequestStore requestStore) +{ + private readonly IMutationRequestStore _requestStore = requestStore ?? throw new ArgumentNullException(nameof(requestStore)); + private readonly MutationRequestApprovalPersistence _persistence = new(requestStore); + + /// + /// Expires approval requirements and rejects requests when their approval deadlines have elapsed. + /// + public async Task> ExpirePendingApprovals( + DateTimeOffset now, + MutationContext decisionContext, + CancellationToken cancellationToken) + { + var pendingRequests = await _requestStore.GetPending(PendingMutationReason.Approval, cancellationToken).ConfigureAwait(false); + var expiredRequests = new List(); + + foreach (var request in pendingRequests) + { + var expiredApprovals = request.ApprovalRequirements + .Where(requirement => + requirement.Status == MutationApprovalRequirementStatus.Pending && + requirement.ExpiresAt is not null && + requirement.ExpiresAt <= now) + .ToList(); + + if (expiredApprovals.Count == 0) + continue; + + var updatedRequirements = request.ApprovalRequirements + .Select(requirement => + { + var expired = expiredApprovals.Any(candidate => candidate.ApprovalId == requirement.ApprovalId); + return expired + ? MutationRequestApprovalWorkflowState.ApplyExpiration(requirement, decisionContext) + : requirement; + }) + .ToList(); + + var decisions = new List(request.Decisions); + decisions.AddRange(expiredApprovals.Select(requirement => + MutationRequestApprovalWorkflowState.CreateApprovalDecision( + MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Expired), + requirement, + decisionContext, + reason: requirement.ExpiresAt is null + ? "Approval requirement expired." + : $"Approval requirement expired at '{requirement.ExpiresAt:O}'."))); + + decisions.Add(MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected), + decisionContext, + reason: "Request was rejected because one or more approval requirements expired.")); + + var updatedRequest = request with + { + Status = MutationRequestStatus.Rejected, + PendingReason = null, + ApprovalRequirements = updatedRequirements, + Decisions = decisions, + UpdatedAt = decisions[^1].Timestamp + }; + + expiredRequests.Add(await _persistence.Persist(request, updatedRequest, cancellationToken).ConfigureAwait(false)); + } + + return expiredRequests; + } +} diff --git a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalWorkflowManager.cs b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalWorkflowManager.cs index ac1f530..ee01fc9 100644 --- a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalWorkflowManager.cs +++ b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalWorkflowManager.cs @@ -1,9 +1,6 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Governance.Abstractions.Approval.Contracts; using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; -using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval; -using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; -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.Storage; @@ -17,11 +14,22 @@ namespace ModularityKit.Mutator.Governance.Runtime.Approval.Execution; public sealed class MutationRequestApprovalWorkflowManager(IMutationRequestStore requestStore) : IMutationRequestApprovalWorkflowManager { - private readonly IMutationRequestStore _requestStore = requestStore ?? throw new ArgumentNullException(nameof(requestStore)); + private readonly MutationRequestApprovalDecisionExecutor _decisionExecutor = + new(requestStore ?? throw new ArgumentNullException(nameof(requestStore))); + + private readonly MutationRequestApprovalExpirationExecutor _expirationExecutor = + new(requestStore ?? throw new ArgumentNullException(nameof(requestStore))); /// - /// Approves a single request-level approval requirement and advances the request when all approvals are satisfied. + /// Approves single request level approval requirement and advances the request when all approvals are satisfied. /// + /// Governed request identifier. + /// Approval requirement identifier. + /// Actor context performing the approval. + /// Optional free-form approval reason. + /// Optional extra decision metadata. + /// Cancellation token. + /// The updated governed request. public Task ApproveRequirement( string requestId, string approvalId, @@ -30,11 +38,12 @@ public Task ApproveRequirement( IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default) { - return ApplyDecision( + return _decisionExecutor.ApplyDecision( requestId, approvalId, decisionContext, reason, + null, metadata, MutationRequestApprovalWorkflowState.ApplyApproval, MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted), @@ -43,21 +52,31 @@ public Task ApproveRequirement( } /// - /// Rejects a single request-level approval requirement and terminates the request lifecycle. + /// Rejects single request level approval requirement and terminates the request lifecycle. /// + /// Governed request identifier. + /// Approval requirement identifier. + /// Actor context performing the rejection. + /// Optional free-form rejection reason. + /// Optional structured rejection payload. + /// Optional extra decision metadata. + /// Cancellation token. + /// The updated governed request. public Task RejectRequirement( string requestId, string approvalId, MutationContext decisionContext, string? reason = null, + MutationApprovalRejectionReason? rejection = null, IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default) { - return ApplyDecision( + return _decisionExecutor.ApplyDecision( requestId, approvalId, decisionContext, reason, + rejection, metadata, MutationRequestApprovalWorkflowState.ApplyRejection, MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Rejected), @@ -65,154 +84,16 @@ public Task RejectRequirement( cancellationToken); } - private async Task ApplyDecision( - string requestId, - string approvalId, + /// + /// Expires pending approval requests whose approval specific deadlines have elapsed. + /// + /// The timestamp used to evaluate approval expiration. + /// Actor context recording the expiration sweep. + /// Cancellation token. + /// The requests that were expired during the sweep. + public async Task> ExpirePendingApprovals( + DateTimeOffset now, MutationContext decisionContext, - string? reason, - IReadOnlyDictionary? metadata, - Func applyResolution, - MutationRequestDecisionType decisionType, - bool finalizeApprovedRequest, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(requestId)) - throw new ArgumentException("Request ID is required.", nameof(requestId)); - - if (string.IsNullOrWhiteSpace(approvalId)) - throw new ArgumentException("Approval ID is required.", nameof(approvalId)); - - ArgumentNullException.ThrowIfNull(decisionContext); - - var request = await GetRequired(requestId, cancellationToken).ConfigureAwait(false); - ValidateApprovalWorkflowRequest(request); - - var approvalRequirement = request.ApprovalRequirements.FirstOrDefault(requirement => requirement.ApprovalId == approvalId); - if (approvalRequirement is null) - throw new MutationApprovalRequirementNotFoundException(request.RequestId, approvalId); - - ValidateApprovalAction(request, approvalRequirement, decisionContext); - - var resolvedRequirement = applyResolution(approvalRequirement, decisionContext, reason); - var updatedRequirements = MutationRequestApprovalWorkflowState.Replace(request.ApprovalRequirements, resolvedRequirement); - var approvalDecision = MutationRequestApprovalWorkflowState.CreateApprovalDecision( - decisionType, - resolvedRequirement, - decisionContext, - reason, - metadata); - - var decisions = new List(request.Decisions) - { - approvalDecision - }; - - var updatedRequest = request with - { - ApprovalRequirements = updatedRequirements - }; - - if (finalizeApprovedRequest) - { - var isFullyApproved = updatedRequirements.All(requirement => requirement.Status == MutationApprovalRequirementStatus.Approved); - updatedRequest = updatedRequest with - { - Status = isFullyApproved ? MutationRequestStatus.Approved : MutationRequestStatus.Pending, - PendingReason = isFullyApproved ? null : PendingMutationReason.Approval - }; - - if (isFullyApproved) - { - decisions.Add(MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), - decisionContext, - reason: "All approval requirements were fulfilled.")); - } - } - else - { - updatedRequest = updatedRequest with - { - Status = MutationRequestStatus.Rejected, - PendingReason = null - }; - - decisions.Add(MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected), - decisionContext, - reason: reason ?? decisionContext.Reason ?? "Request was rejected during approval workflow.")); - } - - updatedRequest = updatedRequest with - { - Decisions = decisions, - UpdatedAt = decisions[^1].Timestamp - }; - - var persistedRequest = await _requestStore - .TryStore(updatedRequest, request.Revision, cancellationToken) - .ConfigureAwait(false); - - if (persistedRequest is null) - throw new MutationRequestConcurrencyException(request.RequestId, request.Revision); - - return persistedRequest; - } - - private static void ValidateApprovalWorkflowRequest(MutationRequest request) - { - if (request.Status != MutationRequestStatus.Pending || request.PendingReason != PendingMutationReason.Approval) - throw new InvalidOperationException( - $"Request '{request.RequestId}' is not in pending approval state."); - - if (request.ApprovalRequirements.Count == 0) - throw new InvalidOperationException( - $"Request '{request.RequestId}' does not define approval requirements."); - } - - private static void ValidateApprovalAction( - MutationRequest request, - MutationApprovalRequirement approvalRequirement, - MutationContext decisionContext) - { - if (approvalRequirement.Status != MutationApprovalRequirementStatus.Pending) - throw new InvalidMutationApprovalActionException( - request.RequestId, - approvalRequirement.ApprovalId, - $"Approval requirement '{approvalRequirement.ApprovalId}' is already {approvalRequirement.Status}."); - - if (string.IsNullOrWhiteSpace(decisionContext.ActorId)) - throw new InvalidMutationApprovalActionException( - request.RequestId, - approvalRequirement.ApprovalId, - "Approval actions require a user or service actor ID."); - - if (!string.Equals(decisionContext.ActorId, approvalRequirement.ApproverId, StringComparison.Ordinal)) - throw new InvalidMutationApprovalActionException( - request.RequestId, - approvalRequirement.ApprovalId, - $"Actor '{decisionContext.ActorId}' is not the expected approver '{approvalRequirement.ApproverId}'."); - - var currentStep = request.ApprovalRequirements - .Where(requirement => requirement.Status == MutationApprovalRequirementStatus.Pending) - .Min(requirement => requirement.StepOrder); - - if (approvalRequirement.StepOrder != currentStep) - throw new InvalidMutationApprovalActionException( - request.RequestId, - approvalRequirement.ApprovalId, - $"Approval requirement '{approvalRequirement.ApprovalId}' is in step {approvalRequirement.StepOrder}, but current active step is {currentStep}."); - } - - 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; - } + CancellationToken cancellationToken = default) => + await _expirationExecutor.ExpirePendingApprovals(now, decisionContext, cancellationToken).ConfigureAwait(false); } diff --git a/src/Governance/Runtime/Approval/Persistence/MutationRequestApprovalPersistence.cs b/src/Governance/Runtime/Approval/Persistence/MutationRequestApprovalPersistence.cs new file mode 100644 index 0000000..ef61a50 --- /dev/null +++ b/src/Governance/Runtime/Approval/Persistence/MutationRequestApprovalPersistence.cs @@ -0,0 +1,30 @@ +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Abstractions.Storage; + +namespace ModularityKit.Mutator.Governance.Runtime.Approval.Persistence; + +/// +/// Persists approval related request transitions with optimistic concurrency checks. +/// +internal sealed class MutationRequestApprovalPersistence(IMutationRequestStore requestStore) +{ + private readonly IMutationRequestStore _requestStore = requestStore ?? throw new ArgumentNullException(nameof(requestStore)); + + /// + /// Persists an approval related request transition using guarded optimistic concurrency. + /// + public async Task Persist( + MutationRequest previousRequest, + MutationRequest nextRequest, + CancellationToken cancellationToken) + { + var persistedRequest = await _requestStore + .TryStore(nextRequest, previousRequest.Revision, cancellationToken) + .ConfigureAwait(false); + + return persistedRequest is null + ? throw new MutationRequestConcurrencyException(previousRequest.RequestId, previousRequest.Revision) + : persistedRequest; + } +} diff --git a/src/Governance/Runtime/Approval/State/MutationRequestApprovalWorkflowState.cs b/src/Governance/Runtime/Approval/State/MutationRequestApprovalWorkflowState.cs index e72187a..c49e052 100644 --- a/src/Governance/Runtime/Approval/State/MutationRequestApprovalWorkflowState.cs +++ b/src/Governance/Runtime/Approval/State/MutationRequestApprovalWorkflowState.cs @@ -5,12 +5,22 @@ namespace ModularityKit.Mutator.Governance.Runtime.Approval.State; +/// +/// Provides approval specific state transformations and decision metadata helpers. +/// internal static class MutationRequestApprovalWorkflowState { + private const string ActorRolesMetadataKey = "ActorRoles"; + private const string ActorGroupsMetadataKey = "ActorGroups"; + + /// + /// Marks an approval requirement as explicitly approved. + /// public static MutationApprovalRequirement ApplyApproval( MutationApprovalRequirement requirement, MutationContext decisionContext, - string? reason) + string? reason, + MutationApprovalRejectionReason? rejection = null) { return requirement with { @@ -21,43 +31,103 @@ public static MutationApprovalRequirement ApplyApproval( }; } + /// + /// Marks an approval requirement as explicitly rejected. + /// public static MutationApprovalRequirement ApplyRejection( MutationApprovalRequirement requirement, MutationContext decisionContext, - string? reason) + string? reason, + MutationApprovalRejectionReason? rejection) { return requirement with { Status = MutationApprovalRequirementStatus.Rejected, DecidedAt = decisionContext.Timestamp, DecisionContext = decisionContext, - DecisionReason = reason ?? decisionContext.Reason + DecisionReason = reason ?? rejection?.Message ?? decisionContext.Reason, + Rejection = rejection }; } - public static IReadOnlyList Replace( - IReadOnlyList requirements, - MutationApprovalRequirement updated) + /// + /// Mark pending approval requirement as satisfied indirectly by group quorum. + /// + public static MutationApprovalRequirement ApplySatisfiedByQuorum( + MutationApprovalRequirement requirement, + MutationContext decisionContext) { - return requirements - .Select(requirement => requirement.ApprovalId == updated.ApprovalId ? updated : requirement) - .ToList(); + return requirement with + { + Status = MutationApprovalRequirementStatus.Satisfied, + DecidedAt = decisionContext.Timestamp, + DecisionContext = decisionContext, + DecisionReason = "Requirement was satisfied by approval quorum." + }; } + /// + /// Marks pending approval requirement as expired. + /// + public static MutationApprovalRequirement ApplyExpiration( + MutationApprovalRequirement requirement, + MutationContext decisionContext) + { + return requirement with + { + Status = MutationApprovalRequirementStatus.Expired, + DecidedAt = decisionContext.Timestamp, + DecisionContext = decisionContext, + DecisionReason = requirement.ExpiresAt is null + ? "Approval requirement expired." + : $"Approval requirement expired at '{requirement.ExpiresAt:O}'." + }; + } + + /// + /// Replaces one approval requirement inside request level requirement collection. + /// + public static IReadOnlyList Replace( + IReadOnlyList requirements, + MutationApprovalRequirement updated) => + [.. requirements.Select(requirement => requirement.ApprovalId == updated.ApprovalId ? updated : requirement)]; + + /// + /// Creates request decision for an approval related action and enriches it with approval metadata. + /// public static MutationRequestDecision CreateApprovalDecision( MutationRequestDecisionType decisionType, MutationApprovalRequirement requirement, MutationContext decisionContext, string? reason, + MutationApprovalRejectionReason? rejection = null, IReadOnlyDictionary? metadata = null) { var mergedMetadata = new Dictionary { ["ApprovalId"] = requirement.ApprovalId, ["ApproverId"] = requirement.ApproverId, - ["StepOrder"] = requirement.StepOrder + ["ApproverRole"] = requirement.ApproverRole ?? string.Empty, + ["ApproverGroup"] = requirement.ApproverGroup ?? string.Empty, + ["StepOrder"] = requirement.StepOrder, + ["ApprovalGroupId"] = requirement.ApprovalGroupId ?? string.Empty, + ["RequiredApprovals"] = requirement.RequiredApprovals }; + if (requirement.ExpiresAt is not null) + mergedMetadata["ApprovalExpiresAt"] = requirement.ExpiresAt.Value; + + if (rejection is not null) + { + mergedMetadata["RejectionCode"] = rejection.Code; + mergedMetadata["RejectionCategory"] = rejection.Category ?? string.Empty; + + foreach (var pair in rejection.Metadata) + { + mergedMetadata[pair.Key] = pair.Value; + } + } + if (metadata is not null) { foreach (var pair in metadata) @@ -69,7 +139,81 @@ public static MutationRequestDecision CreateApprovalDecision( return MutationRequestDecision.Create( decisionType, decisionContext, - reason ?? decisionContext.Reason, + reason ?? rejection?.Message ?? decisionContext.Reason, mergedMetadata); } + + /// + /// Marks remaining pending approvals in quorum group as satisfied once the quorum threshold is reached. + /// + public static IReadOnlyList ApplyQuorumSatisfaction( + IReadOnlyList requirements, + MutationApprovalRequirement resolvedRequirement, + MutationContext decisionContext) + { + if (string.IsNullOrWhiteSpace(resolvedRequirement.ApprovalGroupId)) + return requirements; + + var groupRequirements = requirements + .Where(requirement => + requirement.StepOrder == resolvedRequirement.StepOrder && + string.Equals(requirement.ApprovalGroupId, resolvedRequirement.ApprovalGroupId, StringComparison.Ordinal)) + .ToList(); + + if (groupRequirements.Count <= 1) + return requirements; + + var approvedCount = groupRequirements.Count(requirement => requirement.Status == MutationApprovalRequirementStatus.Approved); + if (approvedCount < resolvedRequirement.RequiredApprovals) + return requirements; + + return [.. requirements + .Select(requirement => + { + var sameGroup = requirement.StepOrder == resolvedRequirement.StepOrder && + string.Equals(requirement.ApprovalGroupId, resolvedRequirement.ApprovalGroupId, StringComparison.Ordinal); + + if (!sameGroup || requirement.Status != MutationApprovalRequirementStatus.Pending) + return requirement; + + return ApplySatisfiedByQuorum(requirement, decisionContext); + })]; + } + + /// + /// Determines whether the actor in the decision context satisfies the approval target definition. + /// + public static bool MatchesApprovalTarget( + MutationApprovalRequirement requirement, + MutationContext decisionContext) + { + if (!string.IsNullOrWhiteSpace(requirement.ApproverId) && + string.Equals(decisionContext.ActorId, requirement.ApproverId, StringComparison.Ordinal)) + return true; + + if (!string.IsNullOrWhiteSpace(requirement.ApproverRole) && + ReadStringSet(decisionContext.Metadata, ActorRolesMetadataKey).Contains(requirement.ApproverRole, StringComparer.Ordinal)) + return true; + + if (!string.IsNullOrWhiteSpace(requirement.ApproverGroup) && + ReadStringSet(decisionContext.Metadata, ActorGroupsMetadataKey).Contains(requirement.ApproverGroup, StringComparer.Ordinal)) + return true; + + return false; + } + + private static IReadOnlyCollection ReadStringSet( + IReadOnlyDictionary metadata, + string key) + { + if (!metadata.TryGetValue(key, out var value)) + return []; + + return value switch + { + IEnumerable typed => typed.Where(static item => !string.IsNullOrWhiteSpace(item)).ToArray(), + IEnumerable objects => objects.OfType().Where(static item => !string.IsNullOrWhiteSpace(item)).ToArray(), + _ => [] + }; + } } diff --git a/src/Governance/Runtime/Approval/Validation/MutationRequestApprovalWorkflowValidator.cs b/src/Governance/Runtime/Approval/Validation/MutationRequestApprovalWorkflowValidator.cs new file mode 100644 index 0000000..f6d13f0 --- /dev/null +++ b/src/Governance/Runtime/Approval/Validation/MutationRequestApprovalWorkflowValidator.cs @@ -0,0 +1,73 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Runtime.Approval.State; + +namespace ModularityKit.Mutator.Governance.Runtime.Approval.Validation; + +/// +/// Validates approval workflow state and approval actions before the runtime mutates a governed request. +/// +internal static class MutationRequestApprovalWorkflowValidator +{ + /// + /// Ensures the request is currently in pending approval state. + /// + public static void ValidateWorkflowRequest(MutationRequest request) + { + if (request.Status != MutationRequestStatus.Pending || request.PendingReason != PendingMutationReason.Approval) + throw new InvalidMutationApprovalWorkflowStateException( + request.RequestId, + $"Request '{request.RequestId}' is not in pending approval state."); + + if (request.ApprovalRequirements.Count == 0) + throw new InvalidMutationApprovalWorkflowStateException( + request.RequestId, + $"Request '{request.RequestId}' does not define approval requirements."); + } + + /// + /// Ensures an approval action is valid for the current request, approval target, and active step. + /// + public static void ValidateApprovalAction( + MutationRequest request, + MutationApprovalRequirement approvalRequirement, + MutationContext decisionContext) + { + if (approvalRequirement.Status != MutationApprovalRequirementStatus.Pending) + throw new InvalidMutationApprovalActionException( + request.RequestId, + approvalRequirement.ApprovalId, + $"Approval requirement '{approvalRequirement.ApprovalId}' is already {approvalRequirement.Status}."); + + if (approvalRequirement.ExpiresAt is not null && approvalRequirement.ExpiresAt <= decisionContext.Timestamp) + throw new MutationApprovalRequirementExpiredException( + request.RequestId, + approvalRequirement.ApprovalId, + approvalRequirement.ExpiresAt.Value); + + if (string.IsNullOrWhiteSpace(decisionContext.ActorId)) + throw new InvalidMutationApprovalActionException( + request.RequestId, + approvalRequirement.ApprovalId, + "Approval actions require a user or service actor ID."); + + if (!MutationRequestApprovalWorkflowState.MatchesApprovalTarget(approvalRequirement, decisionContext)) + throw new InvalidMutationApprovalActionException( + request.RequestId, + approvalRequirement.ApprovalId, + $"Actor '{decisionContext.ActorId}' does not satisfy the approval target for '{approvalRequirement.ApprovalId}'."); + + var currentStep = request.ApprovalRequirements + .Where(requirement => requirement.Status == MutationApprovalRequirementStatus.Pending) + .Min(requirement => requirement.StepOrder); + + if (approvalRequirement.StepOrder != currentStep) + throw new InvalidMutationApprovalActionException( + request.RequestId, + approvalRequirement.ApprovalId, + $"Approval requirement '{approvalRequirement.ApprovalId}' is in step {approvalRequirement.StepOrder}, but current active step is {currentStep}."); + } +} From c087afbb5f9056e9f1df012962df1883a2aa6b2b Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 24 Jun 2026 02:13:23 +0200 Subject: [PATCH 2/2] Docs: Define governance approval workflow hardening --- ..._Governance_Approval_Workflow_Hardening.md | 93 +++++++++++++++++++ Docs/Decision/listadr.md | 1 + 2 files changed, 94 insertions(+) create mode 100644 Docs/Decision/Adr/ADR_028_Governance_Approval_Workflow_Hardening.md diff --git a/Docs/Decision/Adr/ADR_028_Governance_Approval_Workflow_Hardening.md b/Docs/Decision/Adr/ADR_028_Governance_Approval_Workflow_Hardening.md new file mode 100644 index 0000000..ecc3e71 --- /dev/null +++ b/Docs/Decision/Adr/ADR_028_Governance_Approval_Workflow_Hardening.md @@ -0,0 +1,93 @@ +# ADR-028: Governance Approval Workflow Hardening + +## Tag +#adr_028 + +## Status +Accepted + +## Date +2026-06-24 + +## Scope +ModularityKit.Mutator.Governance + +## Context + +The governance package already implements the first approval workflow: + +- request-level approval requirements +- ordered step execution +- explicit approve and reject actions +- approval history recorded through `MutationRequestDecision` + +That first slice is functional, but it is too narrow for operational governance scenarios. + +The main gaps are: + +- approval targets are mostly modeled as individual actors +- grouped approvals and quorum semantics are not first-class +- approval expiration is not modeled independently from generic request expiration +- rejection is recorded, but the business reason model is too thin +- some failure paths still collapse into generic invalid-operation behavior + +Without hardening, governance approval would remain present but not expressive enough for real multi-actor approval workflows. + +## Decision + +The governance package hardens approval workflow semantics in the following way: + +- approval requirements may target: + - a concrete approver id + - an approver role + - an approver group +- approval requirements may participate in approval groups with explicit quorum semantics such as `N-of-M` +- approval requirements may expire independently through approval-specific expiration timestamps +- rejection may carry a structured `MutationApprovalRejectionReason` +- approval-specific invalid states and invalid configurations should raise domain-specific approval exceptions + +The request-centric governance model remains unchanged: + +- approvals are still attached to a `MutationRequest` +- approval actions still resolve request-level approval requirements +- approval outcomes still become request decision history + +Quorum behavior is modeled explicitly: + +- once a grouped approval reaches the required quorum +- remaining pending approvals in that group become `Satisfied` +- request history records a `QuorumSatisfied` approval decision + +Expiration behavior is also explicit: + +- expired approval requirements become `Expired` +- expired approval sets reject the request through governance runtime +- request history records approval expiration and terminal request rejection + +## Design Rationale + +- Governance approvals must support real organizational approval patterns, not only linear actor-by-actor sign-off. +- Role and group targeting fits the existing `MutationContext` model without introducing a separate identity subsystem. +- Quorum semantics belong in governance approval, not in the core mutation engine. +- Structured rejection reasons make approval denial auditable and machine-readable. +- Approval expiration should be explicit because an approval timeout is not the same business event as generic request expiration. + +## Consequences + +### Positive + +- Approval workflow now supports richer operational behavior without leaving the request-centric governance model. +- Multi-actor and grouped approvals become first-class. +- Approval denial and expiration become more auditable. +- Governance runtime can represent approval completion without requiring every approver in a quorum set to act. + +### Negative + +- Approval model complexity increases. +- Approval targeting now depends on agreed metadata conventions for actor roles and groups. +- Future persistence providers will need to store richer approval state and rejection metadata. + +## Related ADRs + +- ADR-025: Governance Approval Workflow +- ADR-027: Governed Execution Manager diff --git a/Docs/Decision/listadr.md b/Docs/Decision/listadr.md index 83a3692..0eee473 100644 --- a/Docs/Decision/listadr.md +++ b/Docs/Decision/listadr.md @@ -43,5 +43,6 @@ These ADRs describe the `ModularityKit.Mutator.Governance` extension layer and i | ADR-025 | Governance Approval Workflow | [ADR-025](Adr/ADR_025_Governance_Approval_Workflow.md) | | ADR-026 | Governance Request Query API | [ADR-026](Adr/ADR_026_Governance_Request_Query_API.md) | | ADR-027 | Governed Execution Manager | [ADR-027](Adr/ADR_027_Governed_Execution_Manager.md) | +| ADR-028 | Governance Approval Workflow Hardening | [ADR-028](Adr/ADR_028_Governance_Approval_Workflow_Hardening.md) | > See individual ADRs for detailed context, decision rationale, and consequences.