diff --git a/.github/workflows/publish-attested.yml b/.github/workflows/publish-attested.yml index f3324ef..b3b6870 100644 --- a/.github/workflows/publish-attested.yml +++ b/.github/workflows/publish-attested.yml @@ -2,6 +2,11 @@ name: Publish Attested on: workflow_dispatch: + inputs: + version: + description: Release version without the leading "v" + required: false + type: string permissions: contents: write @@ -12,6 +17,8 @@ permissions: jobs: publish: uses: ./.github/workflows/publish-artifacts.yml + with: + package_version: ${{ inputs.version }} release: name: Upload artifacts to draft release diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index fecc991..7a2a5c7 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -37,6 +37,8 @@ jobs: publish: needs: update-release-draft uses: ./.github/workflows/publish-artifacts.yml + with: + package_version: ${{ needs.update-release-draft.outputs.tag_name }} upload-release-assets: name: Upload artifacts to release draft diff --git a/Docs/Decision/Adr/ADR_025_Governance_Approval_Workflow.md b/Docs/Decision/Adr/ADR_025_Governance_Approval_Workflow.md index 894a19b..9290130 100644 --- a/Docs/Decision/Adr/ADR_025_Governance_Approval_Workflow.md +++ b/Docs/Decision/Adr/ADR_025_Governance_Approval_Workflow.md @@ -4,7 +4,7 @@ #adr_025 ## Status -Proposed +Accepted ## Date 2026-06-22 diff --git a/Examples/Governance/ApprovalWorkflow/ApprovalWorkflow.csproj b/Examples/Governance/ApprovalWorkflow/ApprovalWorkflow.csproj new file mode 100644 index 0000000..cd1293b --- /dev/null +++ b/Examples/Governance/ApprovalWorkflow/ApprovalWorkflow.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/Examples/Governance/ApprovalWorkflow/Program.cs b/Examples/Governance/ApprovalWorkflow/Program.cs new file mode 100644 index 0000000..fb20905 --- /dev/null +++ b/Examples/Governance/ApprovalWorkflow/Program.cs @@ -0,0 +1,3 @@ +using ApprovalWorkflow.Scenarios; + +await GovernanceApprovalWorkflowScenario.Run(); diff --git a/Examples/Governance/ApprovalWorkflow/README.md b/Examples/Governance/ApprovalWorkflow/README.md new file mode 100644 index 0000000..d052900 --- /dev/null +++ b/Examples/Governance/ApprovalWorkflow/README.md @@ -0,0 +1,23 @@ +# Governance ApprovalWorkflow + +This example shows the governance approval workflow built on top of `MutationRequest.PendingApproval(...)` and `MutationRequestApprovalWorkflowManager`. + +It demonstrates: + +- mapping `PolicyRequirement` into request-level approval requirements +- multi-actor approvals in the same step +- ordered approval steps +- transition from `Pending` to `Approved` after the final approval + +## Key files + +- [`Program.cs`](Program.cs) +- [`Scenarios/GovernanceApprovalWorkflowScenario.cs`](Scenarios/GovernanceApprovalWorkflowScenario.cs) +- [`src/Governance/Runtime/Approval/MutationRequestApprovalWorkflowManager.cs`](../../../src/Governance/Runtime/Approval/MutationRequestApprovalWorkflowManager.cs) +- [`src/Governance/Abstractions/Approval/MutationApprovalRequirement.cs`](../../../src/Governance/Abstractions/Approval/MutationApprovalRequirement.cs) + +## Run + +```bash +dotnet run --project Examples/Governance/ApprovalWorkflow/ApprovalWorkflow.csproj +``` diff --git a/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs b/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs new file mode 100644 index 0000000..d436d61 --- /dev/null +++ b/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs @@ -0,0 +1,111 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Runtime.Approval.Execution; +using ModularityKit.Mutator.Governance.Runtime.Storage; + +namespace ApprovalWorkflow.Scenarios; + +internal static class GovernanceApprovalWorkflowScenario +{ + public static async Task Run() + { + var store = new InMemoryMutationRequestStore(); + var manager = new MutationRequestApprovalWorkflowManager(store); + + PrintSection("Submit Pending Approval Request"); + var request = await store.Create(CreateApprovalRequest()); + PrintRequest(request); + + PrintSection("Approve Step 1"); + var aliceApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice"); + var afterAlice = await manager.ApproveRequirement( + request.RequestId, + aliceApproval.ApprovalId, + MutationContext.User("alice", "Alice", "Manager approved")); + PrintRequest(afterAlice); + + PrintSection("Approve Step 1 - Second Actor"); + var bobApproval = afterAlice.ApprovalRequirements.Single(requirement => requirement.ApproverId == "bob"); + var afterBob = await manager.ApproveRequirement( + request.RequestId, + bobApproval.ApprovalId, + MutationContext.User("bob", "Bob", "Security approved")); + PrintRequest(afterBob); + + PrintSection("Approve Step 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); + } + + private static MutationRequest CreateApprovalRequest() + { + return MutationRequest.PendingApproval( + 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", "Requester", "Need elevated access for incident"), + requirements: + [ + PolicyRequirement.Approval("alice", "Manager approval"), + new PolicyRequirement + { + Type = "Approval", + Description = "Security review", + Data = new + { + Approver = "bob", + StepOrder = 1, + Reason = "Security sign-off" + } + }, + new PolicyRequirement + { + Type = "Approval", + Description = "Finance review", + Data = new + { + Approver = "carol", + StepOrder = 2, + Reason = "Budget sign-off" + } + } + ], + expectedStateVersion: "v10"); + } + + private static void PrintSection(string title) + { + Console.WriteLine(); + Console.WriteLine($"=== {title} ==="); + } + + private static void PrintRequest(MutationRequest request) + { + Console.WriteLine($"Request status: {request.Status}"); + Console.WriteLine($"Pending reason: {request.PendingReason?.ToString() ?? "-"}"); + Console.WriteLine($"Revision: {request.Revision}"); + Console.WriteLine("Approval requirements:"); + + foreach (var requirement in request.ApprovalRequirements.OrderBy(requirement => requirement.StepOrder).ThenBy(requirement => requirement.ApproverId)) + { + Console.WriteLine( + $" - Step {requirement.StepOrder}: {requirement.ApproverId} => {requirement.Status}"); + } + + var lastDecision = request.Decisions[^1]; + Console.WriteLine($"Last decision: {lastDecision.Type} by {lastDecision.Context.ActorId ?? "system"}"); + Console.WriteLine($"Reason: {lastDecision.Reason ?? "-"}"); + } +} diff --git a/Examples/Governance/DecisionTaxonomy/DecisionTaxonomy.csproj b/Examples/Governance/DecisionTaxonomy/DecisionTaxonomy.csproj new file mode 100644 index 0000000..cd1293b --- /dev/null +++ b/Examples/Governance/DecisionTaxonomy/DecisionTaxonomy.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/Examples/Governance/DecisionTaxonomy/Program.cs b/Examples/Governance/DecisionTaxonomy/Program.cs new file mode 100644 index 0000000..78eb8cf --- /dev/null +++ b/Examples/Governance/DecisionTaxonomy/Program.cs @@ -0,0 +1,3 @@ +using DecisionTaxonomy.Scenarios; + +GovernanceDecisionTaxonomyScenario.Run(); diff --git a/Examples/Governance/DecisionTaxonomy/README.md b/Examples/Governance/DecisionTaxonomy/README.md new file mode 100644 index 0000000..5f12cd9 --- /dev/null +++ b/Examples/Governance/DecisionTaxonomy/README.md @@ -0,0 +1,29 @@ +# Governance DecisionTaxonomy + +This example shows why governance request decisions are split into separate categories instead of being kept in one flat enum. + +It demonstrates: + +- lifecycle decisions such as `Submitted` and `Approved` +- approval decisions such as `Requested`, `Granted`, and `Rejected` +- version-resolution decisions such as `Validated` and `RejectedAsStale` +- the shared `MutationRequestDecisionType` wrapper with: + - `Category` + - `Code` + - `ToString()` + +## Key files + +- [`Program.cs`](Program.cs) +- [`Scenarios/GovernanceDecisionTaxonomyScenario.cs`](Scenarios/GovernanceDecisionTaxonomyScenario.cs) +- [`src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionType.cs`](../../../src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionType.cs) +- [`src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionCategory.cs`](../../../src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionCategory.cs) +- [`src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs`](../../../src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs) +- [`src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs`](../../../src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs) +- [`src/Governance/Abstractions/Requests/Decisions/MutationRequestVersionResolutionDecisionType.cs`](../../../src/Governance/Abstractions/Requests/Decisions/MutationRequestVersionResolutionDecisionType.cs) + +## Run + +```bash +dotnet run --project Examples/Governance/DecisionTaxonomy/DecisionTaxonomy.csproj +``` diff --git a/Examples/Governance/DecisionTaxonomy/Scenarios/GovernanceDecisionTaxonomyScenario.cs b/Examples/Governance/DecisionTaxonomy/Scenarios/GovernanceDecisionTaxonomyScenario.cs new file mode 100644 index 0000000..07a8dd1 --- /dev/null +++ b/Examples/Governance/DecisionTaxonomy/Scenarios/GovernanceDecisionTaxonomyScenario.cs @@ -0,0 +1,64 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; + +namespace DecisionTaxonomy.Scenarios; + +internal static class GovernanceDecisionTaxonomyScenario +{ + public static void Run() + { + PrintSection("Lifecycle Decisions"); + PrintDecision(MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationContext.User("requester", "Requester", "Submit request"), + reason: "Request was submitted into governance.")); + PrintDecision(MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + MutationContext.User("system", "System", "Request reached executable state"), + reason: "Request is now approved for execution.")); + + PrintSection("Approval Decisions"); + PrintDecision(MutationRequestDecision.Create( + MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), + MutationContext.User("requester", "Requester", "Approval needed for sensitive change"), + reason: "Sensitive change requires explicit sign-off.")); + PrintDecision(MutationRequestDecision.Create( + MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted), + MutationContext.User("alice", "Alice", "Manager approved"), + reason: "Manager granted the required approval.")); + PrintDecision(MutationRequestDecision.Create( + MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Rejected), + MutationContext.User("bob", "Bob", "Security rejected"), + reason: "Security review rejected the request.")); + + PrintSection("Version Resolution Decisions"); + PrintDecision(MutationRequestDecision.Create( + MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.Validated), + MutationContext.User("approver", "Approver", "Version still matches"), + reason: "Current state version still matches the approved request.")); + PrintDecision(MutationRequestDecision.Create( + MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RejectedAsStale), + MutationContext.User("approver", "Approver", "State drift invalidated the request"), + reason: "Request was rejected because the approved version is stale.")); + PrintDecision(MutationRequestDecision.Create( + MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RenewedApprovalRequired), + MutationContext.User("approver", "Approver", "Re-approval required on latest state"), + reason: "Request must be approved again on the latest state version.")); + } + + private static void PrintSection(string title) + { + Console.WriteLine(); + Console.WriteLine($"=== {title} ==="); + } + + private static void PrintDecision(MutationRequestDecision decision) + { + Console.WriteLine($"Category: {decision.Type.Category}"); + Console.WriteLine($"Code: {decision.Type.Code}"); + Console.WriteLine($"Display: {decision.Type}"); + Console.WriteLine($"Actor: {decision.Context.ActorId ?? "system"}"); + Console.WriteLine($"Reason: {decision.Reason ?? "-"}"); + Console.WriteLine(); + } +} diff --git a/Examples/Governance/RequestLifecycle/README.md b/Examples/Governance/RequestLifecycle/README.md index 94a45c9..722c97f 100644 --- a/Examples/Governance/RequestLifecycle/README.md +++ b/Examples/Governance/RequestLifecycle/README.md @@ -21,7 +21,7 @@ It focuses on `MutationRequest`, `IMutationRequestStore`, and `MutationRequestLi - [`Program.cs`](Program.cs) - [`Scenarios/GovernanceRequestLifecycleScenario.cs`](Scenarios/GovernanceRequestLifecycleScenario.cs) -- [`src/Governance/Abstractions/Requests/MutationRequest.cs`](../../../src/Governance/Abstractions/Requests/MutationRequest.cs) +- [`src/Governance/Abstractions/Requests/Model/MutationRequest.cs`](../../../src/Governance/Abstractions/Requests/Model/MutationRequest.cs) - [`src/Governance/Abstractions/Lifecycle/IMutationRequestLifecycleManager.cs`](../../../src/Governance/Abstractions/Lifecycle/IMutationRequestLifecycleManager.cs) - [`src/Governance/Runtime/MutationRequestLifecycleManager.cs`](../../../src/Governance/Runtime/MutationRequestLifecycleManager.cs) - [`src/Governance/Runtime/InMemoryMutationRequestStore.cs`](../../../src/Governance/Runtime/InMemoryMutationRequestStore.cs) diff --git a/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs b/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs index 9cb2473..469aa89 100644 --- a/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs +++ b/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs @@ -1,9 +1,9 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Policies; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; -using ModularityKit.Mutator.Governance.Abstractions.Requests; -using ModularityKit.Mutator.Governance.Runtime.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Runtime.Lifecycle.Execution; using ModularityKit.Mutator.Governance.Runtime.Storage; namespace RequestLifecycle.Scenarios; diff --git a/Examples/Governance/VersionedResolution/README.md b/Examples/Governance/VersionedResolution/README.md index cabddd0..0b684e1 100644 --- a/Examples/Governance/VersionedResolution/README.md +++ b/Examples/Governance/VersionedResolution/README.md @@ -17,10 +17,10 @@ It is the direct runnable example for the semantics introduced around `ExpectedS - [`Program.cs`](Program.cs) - [`Scenarios/GovernanceVersionedResolutionScenario.cs`](Scenarios/GovernanceVersionedResolutionScenario.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) +- [`src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolver.cs`](../../../src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolver.cs) +- [`src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionManager.cs`](../../../src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionManager.cs) +- [`src/Governance/Abstractions/Resolution/Strategies/VersionedRequestResolutionStrategy.cs`](../../../src/Governance/Abstractions/Resolution/Strategies/VersionedRequestResolutionStrategy.cs) +- [`src/Governance/Abstractions/Resolution/Model/MutationRequestVersionResolution.cs`](../../../src/Governance/Abstractions/Resolution/Model/MutationRequestVersionResolution.cs) ## Run diff --git a/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs b/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs index f993c23..75096b9 100644 --- a/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs +++ b/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs @@ -1,8 +1,9 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Intent; -using ModularityKit.Mutator.Governance.Abstractions.Requests; -using ModularityKit.Mutator.Governance.Abstractions.Resolution; -using ModularityKit.Mutator.Governance.Runtime.Resolution; +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; namespace VersionedResolution.Scenarios; diff --git a/Examples/README.md b/Examples/README.md index 6cf35b8..da8142f 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -23,6 +23,8 @@ The projects are intentionally small and focused. Each one demonstrates a differ | Example | Focus | Readme | | --- | --- | --- | | `RequestLifecycle` | pending requests, lifecycle transitions, expiration, and cancellation | [`Examples/Governance/RequestLifecycle/README.md`](Governance/RequestLifecycle/README.md) | +| `DecisionTaxonomy` | lifecycle, approval, and version-resolution decision categories | [`Examples/Governance/DecisionTaxonomy/README.md`](Governance/DecisionTaxonomy/README.md) | +| `ApprovalWorkflow` | request-level approvals, multi-step sign-off, and governed approval actions | [`Examples/Governance/ApprovalWorkflow/README.md`](Governance/ApprovalWorkflow/README.md) | | `VersionedResolution` | stale request handling and expected state version semantics | [`Examples/Governance/VersionedResolution/README.md`](Governance/VersionedResolution/README.md) | ## How to use these examples @@ -52,6 +54,8 @@ dotnet build Examples/Core/FeatureFlags/FeatureFlags.csproj -c Release dotnet build Examples/Core/IamRoles/IamRoles.csproj -c Release dotnet build Examples/Core/WorkflowApprovals/WorkflowApprovals.csproj -c Release dotnet build Examples/Governance/RequestLifecycle/RequestLifecycle.csproj -c Release +dotnet build Examples/Governance/DecisionTaxonomy/DecisionTaxonomy.csproj -c Release +dotnet build Examples/Governance/ApprovalWorkflow/ApprovalWorkflow.csproj -c Release dotnet build Examples/Governance/VersionedResolution/VersionedResolution.csproj -c Release ``` @@ -67,6 +71,8 @@ dotnet run --project Examples/Core/FeatureFlags/FeatureFlags.csproj dotnet run --project Examples/Core/IamRoles/IamRoles.csproj dotnet run --project Examples/Core/WorkflowApprovals/WorkflowApprovals.csproj dotnet run --project Examples/Governance/RequestLifecycle/RequestLifecycle.csproj +dotnet run --project Examples/Governance/DecisionTaxonomy/DecisionTaxonomy.csproj +dotnet run --project Examples/Governance/ApprovalWorkflow/ApprovalWorkflow.csproj dotnet run --project Examples/Governance/VersionedResolution/VersionedResolution.csproj ``` @@ -131,6 +137,18 @@ Shows the governance runtime as a request lifecycle system instead of an immedia See [`Governance/RequestLifecycle/README.md`](Governance/RequestLifecycle/README.md). +### ApprovalWorkflow + +Shows how governance turns approval requirements into explicit request-level approval actions with ordered steps and terminal approval or rejection behavior. + +See [`Governance/ApprovalWorkflow/README.md`](Governance/ApprovalWorkflow/README.md). + +### DecisionTaxonomy + +Shows why governance request decisions are split into lifecycle, approval, and version-resolution categories instead of being kept in one flat enum. + +See [`Governance/DecisionTaxonomy/README.md`](Governance/DecisionTaxonomy/README.md). + ### VersionedResolution Shows how governance resolves approved requests once the underlying state version has moved. This is the example to read if you want concrete stale request semantics. diff --git a/ModularityKit.Mutator.slnx b/ModularityKit.Mutator.slnx index 5a304d3..27cfc19 100644 --- a/ModularityKit.Mutator.slnx +++ b/ModularityKit.Mutator.slnx @@ -8,6 +8,8 @@ + + diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs new file mode 100644 index 0000000..dde1c2c --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs @@ -0,0 +1,188 @@ +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.Exceptions.Approval; +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.Runtime.Approval.Execution; +using ModularityKit.Mutator.Governance.Runtime.Storage; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Approval; + +public sealed class MutationRequestApprovalWorkflowTests +{ + [Fact] + public void PendingApproval_maps_policy_requirements_into_visible_request_approval_requirements() + { + var request = MutationRequest.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"), + new PolicyRequirement + { + Type = "Approval", + Description = "Security and finance approval", + Data = new + { + Approvers = new[] { "bob", "carol" }, + StepOrder = 2, + Reason = "Cross-functional sign-off" + } + } + ], + expectedStateVersion: "v10"); + + 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); + }); + } + + [Fact] + public async Task ApproveRequirement_enforces_step_order_and_marks_request_approved_after_final_approval() + { + 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 aliceApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice"); + var afterAlice = await manager.ApproveRequirement( + request.RequestId, + aliceApproval.ApprovalId, + 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( + request.RequestId, + bobApproval.ApprovalId, + MutationContext.User("bob", "Bob", "Security approved")); + + Assert.Equal(MutationRequestStatus.Pending, afterBob.Status); + + var finalCarolApproval = 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)); + } + + [Fact] + public async Task RejectRequirement_marks_request_rejected_and_records_explicit_history() + { + var store = new InMemoryMutationRequestStore(); + var manager = new MutationRequestApprovalWorkflowManager(store); + var request = await store.Create(CreateMultiStepApprovalRequest()); + var aliceApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice"); + + var rejected = await manager.RejectRequirement( + request.RequestId, + aliceApproval.ApprovalId, + MutationContext.User("alice", "Alice", "Manager rejected"), + reason: "Insufficient justification"); + + 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"); + } + + private static MutationRequest CreateMultiStepApprovalRequest() + { + return MutationRequest.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"), + new PolicyRequirement + { + Type = "Approval", + Description = "Security review", + Data = new + { + Approver = "bob", + StepOrder = 1, + Reason = "Security sign-off" + } + }, + new PolicyRequirement + { + Type = "Approval", + Description = "Finance review", + Data = new + { + Approver = "carol", + StepOrder = 2, + Reason = "Budget sign-off" + } + } + ], + expectedStateVersion: "v10"); + } + + private static MutationIntent CreateIntent() + { + return new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated role to tenant operator" + }; + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs index c7060c1..8264d18 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs @@ -1,7 +1,7 @@ using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Governance.Abstractions.Exceptions; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; -using ModularityKit.Mutator.Governance.Runtime.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Runtime.Lifecycle.Execution; using ModularityKit.Mutator.Governance.Tests.TestSupport; using Xunit; diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs index e5d1e4b..c5140a5 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs @@ -1,7 +1,8 @@ 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.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.Runtime.Storage; using ModularityKit.Mutator.Governance.Tests.TestSupport; using Xunit; @@ -39,7 +40,7 @@ public async Task TryStore_rejects_stale_revision_and_preserves_current_state() [ .. created.Decisions, MutationRequestDecision.Create( - MutationRequestDecisionType.Approved, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), MutationContext.User("approver", "Approver", "Approve request")) ] }; @@ -55,7 +56,7 @@ public async Task TryStore_rejects_stale_revision_and_preserves_current_state() [ .. created.Decisions, MutationRequestDecision.Create( - MutationRequestDecisionType.Canceled, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Canceled), MutationContext.User("operator", "Operator", "Cancel request")) ] }; diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs index 7d28a5e..da0844c 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs @@ -1,9 +1,10 @@ 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.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.Resolution.Strategies; +using ModularityKit.Mutator.Governance.Runtime.Resolution.Execution; using ModularityKit.Mutator.Governance.Runtime.Storage; using ModularityKit.Mutator.Governance.Tests.TestSupport; using Xunit; @@ -54,7 +55,9 @@ public async Task ResolveAndStore_persists_decision_history_and_state() Assert.NotNull(loaded); Assert.Equal(3, loaded.Decisions.Count); - Assert.Equal(MutationRequestDecisionType.RejectedAsStale, loaded.Decisions[^1].Type); + Assert.Equal( + MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RejectedAsStale), + loaded.Decisions[^1].Type); Assert.Equal(MutationRequestStatus.Rejected, loaded.Status); Assert.Equal(1, loaded.Revision); Assert.Equal(loaded, resolution.Request); diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs index c5fe195..67849cb 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs @@ -1,7 +1,7 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Intent; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; -using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Tests.TestSupport; diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs index 2e9d559..e7be126 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs @@ -1,5 +1,5 @@ -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; -using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Storage; namespace ModularityKit.Mutator.Governance.Tests.TestSupport; diff --git a/src/Governance/Abstractions/Approval/Contracts/IMutationRequestApprovalWorkflowManager.cs b/src/Governance/Abstractions/Approval/Contracts/IMutationRequestApprovalWorkflowManager.cs new file mode 100644 index 0000000..b9bfdc2 --- /dev/null +++ b/src/Governance/Abstractions/Approval/Contracts/IMutationRequestApprovalWorkflowManager.cs @@ -0,0 +1,32 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Approval.Contracts; + +/// +/// Resolves approval requirements attached to governed mutation requests. +/// +public interface IMutationRequestApprovalWorkflowManager +{ + /// + /// Approves one pending approval requirement on a request. + /// + Task ApproveRequirement( + string requestId, + string approvalId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default); + + /// + /// Rejects one pending approval requirement on a request and terminates the request. + /// + Task RejectRequirement( + string requestId, + string approvalId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default); +} diff --git a/src/Governance/Abstractions/Approval/Mapping/MutationApprovalRequirementMapper.cs b/src/Governance/Abstractions/Approval/Mapping/MutationApprovalRequirementMapper.cs new file mode 100644 index 0000000..f4bc7d7 --- /dev/null +++ b/src/Governance/Abstractions/Approval/Mapping/MutationApprovalRequirementMapper.cs @@ -0,0 +1,112 @@ +using System.Collections; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Approval.Mapping; + +internal static class MutationApprovalRequirementMapper +{ + public static IReadOnlyList Map( + IReadOnlyList? requirements) + { + if (requirements is null || requirements.Count == 0) + return []; + + var mapped = new List(); + var approvalIndex = 0; + + foreach (var requirement in requirements) + { + if (!string.Equals(requirement.Type, "Approval", StringComparison.Ordinal)) + continue; + + var approvalDefinitions = ExtractApprovalDefinitions(requirement, approvalIndex); + mapped.AddRange(approvalDefinitions); + approvalIndex++; + } + + return mapped; + } + + private static IReadOnlyList ExtractApprovalDefinitions( + PolicyRequirement requirement, + int defaultStepOrder) + { + var stepOrder = ReadIntProperty(requirement.Data, "StepOrder") ?? defaultStepOrder + 1; + var approverName = ReadStringProperty(requirement.Data, "ApproverName"); + var reason = ReadStringProperty(requirement.Data, "Reason"); + + var approvers = ReadStringSequenceProperty(requirement.Data, "Approvers"); + if (approvers.Count == 0) + { + var approver = ReadStringProperty(requirement.Data, "Approver"); + if (!string.IsNullOrWhiteSpace(approver)) + approvers = [approver]; + } + + if (approvers.Count == 0) + throw new InvalidOperationException( + $"Approval requirement '{requirement.Description}' does not define an approver."); + + return approvers + .Select(approverId => new MutationApprovalRequirement + { + Type = requirement.Type, + Description = requirement.Description, + ApproverId = approverId, + ApproverName = approverName, + StepOrder = stepOrder, + Metadata = new Dictionary + { + ["RequirementDescription"] = requirement.Description, + ["RequirementReason"] = reason ?? string.Empty + } + }) + .ToList(); + } + + private static string? ReadStringProperty(object? source, string propertyName) + { + if (source is null) + return null; + + var property = source.GetType().GetProperty(propertyName); + var value = property?.GetValue(source); + return value as string; + } + + private static int? ReadIntProperty(object? source, string propertyName) + { + if (source is null) + return null; + + var property = source.GetType().GetProperty(propertyName); + var value = property?.GetValue(source); + return value switch + { + int intValue => intValue, + long longValue => checked((int)longValue), + short shortValue => shortValue, + _ => null + }; + } + + private static List ReadStringSequenceProperty(object? source, string propertyName) + { + if (source is null) + return []; + + var property = source.GetType().GetProperty(propertyName); + var value = property?.GetValue(source); + + return value switch + { + IEnumerable typedStrings => typedStrings.Where(static x => !string.IsNullOrWhiteSpace(x)).ToList(), + IEnumerable sequence => sequence.Cast() + .OfType() + .Where(static x => !string.IsNullOrWhiteSpace(x)) + .ToList(), + _ => [] + }; + } +} diff --git a/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirement.cs b/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirement.cs new file mode 100644 index 0000000..d6b0111 --- /dev/null +++ b/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirement.cs @@ -0,0 +1,65 @@ +using ModularityKit.Mutator.Abstractions.Context; + +namespace ModularityKit.Mutator.Governance.Abstractions.Approval.Model; + +/// +/// Represents one concrete approval action that must be completed before a governed request can proceed. +/// +public sealed record MutationApprovalRequirement +{ + /// + /// Stable identifier of the approval requirement inside the request. + /// + public string ApprovalId { get; init; } = Guid.NewGuid().ToString(); + + /// + /// Requirement type copied from the originating policy requirement. + /// + public string Type { get; init; } = string.Empty; + + /// + /// Human-readable description of the approval requirement. + /// + public string Description { get; init; } = string.Empty; + + /// + /// Identifier of the approver who is allowed to resolve this requirement. + /// + public string ApproverId { get; init; } = string.Empty; + + /// + /// Optional human-readable name of the approver. + /// + public string? ApproverName { get; init; } + + /// + /// Step number used for ordered approval workflows. + /// Requirements in the same step may be resolved in any order. + /// + public int StepOrder { get; init; } = 1; + + /// + /// Current state of the approval requirement. + /// + public MutationApprovalRequirementStatus Status { get; init; } = MutationApprovalRequirementStatus.Pending; + + /// + /// Timestamp when the requirement was resolved. + /// + public DateTimeOffset? DecidedAt { get; init; } + + /// + /// Context of the actor who resolved this requirement. + /// + public MutationContext? DecisionContext { get; init; } + + /// + /// Optional human-readable reason attached to the approval decision. + /// + public string? DecisionReason { get; init; } + + /// + /// Additional approval metadata for integrations and audit trails. + /// + public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); +} diff --git a/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirementStatus.cs b/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirementStatus.cs new file mode 100644 index 0000000..b13e233 --- /dev/null +++ b/src/Governance/Abstractions/Approval/Model/MutationApprovalRequirementStatus.cs @@ -0,0 +1,11 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Approval.Model; + +/// +/// Represents the current state of a request-level approval requirement. +/// +public enum MutationApprovalRequirementStatus +{ + Pending = 0, + Approved = 1, + Rejected = 2 +} diff --git a/src/Governance/Abstractions/Exceptions/Approval/InvalidMutationApprovalActionException.cs b/src/Governance/Abstractions/Exceptions/Approval/InvalidMutationApprovalActionException.cs new file mode 100644 index 0000000..f708148 --- /dev/null +++ b/src/Governance/Abstractions/Exceptions/Approval/InvalidMutationApprovalActionException.cs @@ -0,0 +1,20 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval; + +/// +/// Raised when an approval action is not valid for the current request or approval state. +/// +public sealed class InvalidMutationApprovalActionException( + string requestId, + string approvalId, + string message) : InvalidOperationException(message) +{ + /// + /// Request identifier on which the invalid approval action was attempted. + /// + public string RequestId { get; } = requestId; + + /// + /// Approval identifier on which the invalid action was attempted. + /// + public string ApprovalId { get; } = approvalId; +} diff --git a/src/Governance/Abstractions/Exceptions/Approval/MutationApprovalRequirementNotFoundException.cs b/src/Governance/Abstractions/Exceptions/Approval/MutationApprovalRequirementNotFoundException.cs new file mode 100644 index 0000000..2485d52 --- /dev/null +++ b/src/Governance/Abstractions/Exceptions/Approval/MutationApprovalRequirementNotFoundException.cs @@ -0,0 +1,20 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval; + +/// +/// Raised when a governed request does not contain the targeted approval requirement. +/// +public sealed class MutationApprovalRequirementNotFoundException( + string requestId, + string approvalId) : KeyNotFoundException( + $"Approval requirement '{approvalId}' was not found on request '{requestId}'.") +{ + /// + /// Request identifier that was queried. + /// + public string RequestId { get; } = requestId; + + /// + /// Missing approval identifier. + /// + public string ApprovalId { get; } = approvalId; +} diff --git a/src/Governance/Abstractions/Exceptions/InvalidMutationRequestTransitionException.cs b/src/Governance/Abstractions/Exceptions/Lifecycle/InvalidMutationRequestTransitionException.cs similarity index 92% rename from src/Governance/Abstractions/Exceptions/InvalidMutationRequestTransitionException.cs rename to src/Governance/Abstractions/Exceptions/Lifecycle/InvalidMutationRequestTransitionException.cs index a53205f..b86a79c 100644 --- a/src/Governance/Abstractions/Exceptions/InvalidMutationRequestTransitionException.cs +++ b/src/Governance/Abstractions/Exceptions/Lifecycle/InvalidMutationRequestTransitionException.cs @@ -1,7 +1,7 @@ using ModularityKit.Mutator.Abstractions.Exceptions; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions; +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions.Lifecycle; /// /// Thrown when governance runtime attempts to apply an invalid lifecycle transition. diff --git a/src/Governance/Abstractions/Exceptions/MutationRequestAlreadyExistsException.cs b/src/Governance/Abstractions/Exceptions/Storage/MutationRequestAlreadyExistsException.cs similarity index 97% rename from src/Governance/Abstractions/Exceptions/MutationRequestAlreadyExistsException.cs rename to src/Governance/Abstractions/Exceptions/Storage/MutationRequestAlreadyExistsException.cs index 4ebeebc..875aa8e 100644 --- a/src/Governance/Abstractions/Exceptions/MutationRequestAlreadyExistsException.cs +++ b/src/Governance/Abstractions/Exceptions/Storage/MutationRequestAlreadyExistsException.cs @@ -1,6 +1,6 @@ using ModularityKit.Mutator.Abstractions.Exceptions; -namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions; +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; /// /// Thrown when governance storage is asked to create a request that already exists. diff --git a/src/Governance/Abstractions/Exceptions/MutationRequestConcurrencyException.cs b/src/Governance/Abstractions/Exceptions/Storage/MutationRequestConcurrencyException.cs similarity index 98% rename from src/Governance/Abstractions/Exceptions/MutationRequestConcurrencyException.cs rename to src/Governance/Abstractions/Exceptions/Storage/MutationRequestConcurrencyException.cs index 90752f8..78deeae 100644 --- a/src/Governance/Abstractions/Exceptions/MutationRequestConcurrencyException.cs +++ b/src/Governance/Abstractions/Exceptions/Storage/MutationRequestConcurrencyException.cs @@ -1,6 +1,6 @@ using ModularityKit.Mutator.Abstractions.Exceptions; -namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions; +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; /// /// Thrown when a governance request transition loses an optimistic concurrency race. diff --git a/src/Governance/Abstractions/Exceptions/MutationRequestNotFoundException.cs b/src/Governance/Abstractions/Exceptions/Storage/MutationRequestNotFoundException.cs similarity index 97% rename from src/Governance/Abstractions/Exceptions/MutationRequestNotFoundException.cs rename to src/Governance/Abstractions/Exceptions/Storage/MutationRequestNotFoundException.cs index 5b02063..ffa37a2 100644 --- a/src/Governance/Abstractions/Exceptions/MutationRequestNotFoundException.cs +++ b/src/Governance/Abstractions/Exceptions/Storage/MutationRequestNotFoundException.cs @@ -1,6 +1,6 @@ using ModularityKit.Mutator.Abstractions.Exceptions; -namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions; +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; /// /// Thrown when governance runtime cannot find a mutation request by its stable identifier. diff --git a/src/Governance/Abstractions/Lifecycle/IMutationRequestLifecycleManager.cs b/src/Governance/Abstractions/Lifecycle/Contracts/IMutationRequestLifecycleManager.cs similarity index 96% rename from src/Governance/Abstractions/Lifecycle/IMutationRequestLifecycleManager.cs rename to src/Governance/Abstractions/Lifecycle/Contracts/IMutationRequestLifecycleManager.cs index a4f9bb2..57befd3 100644 --- a/src/Governance/Abstractions/Lifecycle/IMutationRequestLifecycleManager.cs +++ b/src/Governance/Abstractions/Lifecycle/Contracts/IMutationRequestLifecycleManager.cs @@ -1,7 +1,8 @@ using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +namespace ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Contracts; /// /// Moves governed mutation requests through the runtime pending lifecycle. diff --git a/src/Governance/Abstractions/Lifecycle/MutationRequestStatus.cs b/src/Governance/Abstractions/Lifecycle/Model/MutationRequestStatus.cs similarity index 97% rename from src/Governance/Abstractions/Lifecycle/MutationRequestStatus.cs rename to src/Governance/Abstractions/Lifecycle/Model/MutationRequestStatus.cs index 8532d00..62b3ed2 100644 --- a/src/Governance/Abstractions/Lifecycle/MutationRequestStatus.cs +++ b/src/Governance/Abstractions/Lifecycle/Model/MutationRequestStatus.cs @@ -1,4 +1,4 @@ -namespace ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +namespace ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; /// /// Represents the lifecycle status of governed mutation request. diff --git a/src/Governance/Abstractions/Lifecycle/PendingMutationReason.cs b/src/Governance/Abstractions/Lifecycle/Model/PendingMutationReason.cs similarity index 97% rename from src/Governance/Abstractions/Lifecycle/PendingMutationReason.cs rename to src/Governance/Abstractions/Lifecycle/Model/PendingMutationReason.cs index b9d70e5..9cb767b 100644 --- a/src/Governance/Abstractions/Lifecycle/PendingMutationReason.cs +++ b/src/Governance/Abstractions/Lifecycle/Model/PendingMutationReason.cs @@ -1,4 +1,4 @@ -namespace ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +namespace ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; /// /// Describes why a mutation request cannot execute immediately. diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs new file mode 100644 index 0000000..d3805fc --- /dev/null +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestApprovalDecisionType.cs @@ -0,0 +1,11 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; + +/// +/// Represents approval-specific decisions recorded during governance workflow. +/// +public enum MutationRequestApprovalDecisionType +{ + Requested = 0, + Granted = 1, + Rejected = 2 +} diff --git a/src/Governance/Abstractions/Requests/MutationRequestDecision.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs similarity index 99% rename from src/Governance/Abstractions/Requests/MutationRequestDecision.cs rename to src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs index a241a1b..852272c 100644 --- a/src/Governance/Abstractions/Requests/MutationRequestDecision.cs +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs @@ -1,6 +1,6 @@ using ModularityKit.Mutator.Abstractions.Context; -namespace ModularityKit.Mutator.Governance.Abstractions.Requests; +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; /// /// Captures a single decision or lifecycle transition applied to a mutation request. diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionCategory.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionCategory.cs new file mode 100644 index 0000000..8992837 --- /dev/null +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionCategory.cs @@ -0,0 +1,11 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; + +/// +/// Groups governance request decisions by the runtime concern that produced them. +/// +public enum MutationRequestDecisionCategory +{ + Lifecycle = 0, + Approval = 1, + VersionResolution = 2 +} diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionType.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionType.cs new file mode 100644 index 0000000..aeba990 --- /dev/null +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecisionType.cs @@ -0,0 +1,52 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; + +/// +/// Identifies one governance request decision together with the concern category that produced it. +/// +public readonly record struct MutationRequestDecisionType +{ + /// + /// Decision category. + /// + public MutationRequestDecisionCategory Category { get; init; } + + /// + /// Stable textual code of the decision inside the category. + /// + public string Code { get; init; } + + /// + /// Creates a lifecycle decision type wrapper. + /// + public static MutationRequestDecisionType Lifecycle(MutationRequestLifecycleDecisionType type) + => new() + { + Category = MutationRequestDecisionCategory.Lifecycle, + Code = type.ToString() + }; + + /// + /// Creates an approval decision type wrapper. + /// + public static MutationRequestDecisionType Approval(MutationRequestApprovalDecisionType type) + => new() + { + Category = MutationRequestDecisionCategory.Approval, + Code = type.ToString() + }; + + /// + /// Creates a version-resolution decision type wrapper. + /// + public static MutationRequestDecisionType VersionResolution(MutationRequestVersionResolutionDecisionType type) + => new() + { + Category = MutationRequestDecisionCategory.VersionResolution, + Code = type.ToString() + }; + + /// + /// Returns the stable code of the wrapped decision. + /// + public override string ToString() => Code; +} diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs new file mode 100644 index 0000000..5d35621 --- /dev/null +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs @@ -0,0 +1,16 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; + +/// +/// Represents high-level lifecycle decisions taken against a mutation request. +/// +public enum MutationRequestLifecycleDecisionType +{ + Submitted = 0, + Pending = 1, + Approved = 2, + Rejected = 3, + Canceled = 4, + Expired = 5, + Superseded = 6, + Executed = 7 +} diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestVersionResolutionDecisionType.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestVersionResolutionDecisionType.cs new file mode 100644 index 0000000..5e501cf --- /dev/null +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestVersionResolutionDecisionType.cs @@ -0,0 +1,12 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; + +/// +/// Represents version-resolution decisions recorded while reconciling expected and current state versions. +/// +public enum MutationRequestVersionResolutionDecisionType +{ + Validated = 0, + RevalidationRequired = 1, + RenewedApprovalRequired = 2, + RejectedAsStale = 3 +} diff --git a/src/Governance/Abstractions/Requests/MutationRequest.cs b/src/Governance/Abstractions/Requests/Model/MutationRequest.cs similarity index 62% rename from src/Governance/Abstractions/Requests/MutationRequest.cs rename to src/Governance/Abstractions/Requests/Model/MutationRequest.cs index 9a88746..11f8c96 100644 --- a/src/Governance/Abstractions/Requests/MutationRequest.cs +++ b/src/Governance/Abstractions/Requests/Model/MutationRequest.cs @@ -1,9 +1,12 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Policies; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Mapping; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; -namespace ModularityKit.Mutator.Governance.Abstractions.Requests; +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Model; /// /// Represents a governed mutation request that may execute immediately or enter a pending lifecycle. @@ -55,6 +58,11 @@ public sealed record MutationRequest /// public IReadOnlyList Requirements { get; init; } = []; + /// + /// Concrete request-level approval requirements derived from governance policy requirements. + /// + public IReadOnlyList ApprovalRequirements { get; init; } = []; + /// /// Governance decisions recorded against this request over time. /// @@ -121,17 +129,73 @@ public static MutationRequest Pending( Decisions = [ MutationRequestDecision.Create( - MutationRequestDecisionType.Submitted, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), context, reason: context.Reason), MutationRequestDecision.Create( - MutationRequestDecisionType.Pending, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), context, reason: $"Request entered pending lifecycle for reason '{pendingReason}'.") ] }; } + /// + /// Creates a request that enters pending approval with concrete request-level approval requirements. + /// + public static MutationRequest PendingApproval( + string stateId, + string stateType, + string mutationType, + MutationIntent intent, + MutationContext context, + IReadOnlyList requirements, + string? expectedStateVersion = null, + DateTimeOffset? expiresAt = null, + IReadOnlyDictionary? metadata = null) + { + ArgumentNullException.ThrowIfNull(requirements); + + var approvalRequirements = MutationApprovalRequirementMapper.Map(requirements); + if (approvalRequirements.Count == 0) + throw new InvalidOperationException("Pending approval requests require at least one approval requirement."); + + return new MutationRequest + { + StateId = stateId, + StateType = stateType, + MutationType = mutationType, + Intent = intent, + Context = context, + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval, + Requirements = requirements, + ApprovalRequirements = approvalRequirements, + ExpectedStateVersion = expectedStateVersion, + ExpiresAt = expiresAt, + Metadata = metadata ?? new Dictionary(), + Decisions = + [ + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + context, + reason: context.Reason), + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), + context, + reason: "Request entered pending approval."), + MutationRequestDecision.Create( + MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), + context, + reason: $"Request requires {approvalRequirements.Count} approval action(s).", + metadata: new Dictionary + { + ["ApprovalRequirementCount"] = approvalRequirements.Count + }) + ] + }; + } + /// /// Creates a request that is immediately approved for execution. /// @@ -157,11 +221,11 @@ public static MutationRequest Approved( Decisions = [ MutationRequestDecision.Create( - MutationRequestDecisionType.Submitted, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), context, reason: context.Reason), MutationRequestDecision.Create( - MutationRequestDecisionType.Approved, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), context, reason: "Approved at submission time") ] diff --git a/src/Governance/Abstractions/Requests/MutationRequestDecisionType.cs b/src/Governance/Abstractions/Requests/MutationRequestDecisionType.cs deleted file mode 100644 index 8680681..0000000 --- a/src/Governance/Abstractions/Requests/MutationRequestDecisionType.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ModularityKit.Mutator.Governance.Abstractions.Requests; - -/// -/// Represents a governance decision taken against a mutation request. -/// -public enum MutationRequestDecisionType -{ - Submitted = 0, - Pending = 1, - Approved = 2, - Rejected = 3, - Canceled = 4, - Expired = 5, - Superseded = 6, - Executed = 7, - VersionValidated = 8, - RevalidationRequired = 9, - RenewedApprovalRequired = 10, - RejectedAsStale = 11 -} diff --git a/src/Governance/Abstractions/Resolution/IMutationRequestVersionResolutionManager.cs b/src/Governance/Abstractions/Resolution/Contracts/IMutationRequestVersionResolutionManager.cs similarity index 82% rename from src/Governance/Abstractions/Resolution/IMutationRequestVersionResolutionManager.cs rename to src/Governance/Abstractions/Resolution/Contracts/IMutationRequestVersionResolutionManager.cs index a3e71d3..b9d4aa7 100644 --- a/src/Governance/Abstractions/Resolution/IMutationRequestVersionResolutionManager.cs +++ b/src/Governance/Abstractions/Resolution/Contracts/IMutationRequestVersionResolutionManager.cs @@ -1,6 +1,9 @@ using ModularityKit.Mutator.Abstractions.Context; -namespace ModularityKit.Mutator.Governance.Abstractions.Resolution; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; + +namespace ModularityKit.Mutator.Governance.Abstractions.Resolution.Contracts; /// /// Persists version-aware resolution outcomes for governed mutation requests. diff --git a/src/Governance/Abstractions/Resolution/IMutationRequestVersionResolver.cs b/src/Governance/Abstractions/Resolution/Contracts/IMutationRequestVersionResolver.cs similarity index 74% rename from src/Governance/Abstractions/Resolution/IMutationRequestVersionResolver.cs rename to src/Governance/Abstractions/Resolution/Contracts/IMutationRequestVersionResolver.cs index e61c8e0..41b5356 100644 --- a/src/Governance/Abstractions/Resolution/IMutationRequestVersionResolver.cs +++ b/src/Governance/Abstractions/Resolution/Contracts/IMutationRequestVersionResolver.cs @@ -1,8 +1,9 @@ using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Governance.Abstractions.Resolution; -using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; -namespace ModularityKit.Mutator.Governance.Abstractions.Resolution; +namespace ModularityKit.Mutator.Governance.Abstractions.Resolution.Contracts; /// /// Resolves a governed mutation request against the current state version before execution. diff --git a/src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs b/src/Governance/Abstractions/Resolution/Model/MutationRequestVersionResolution.cs similarity index 93% rename from src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs rename to src/Governance/Abstractions/Resolution/Model/MutationRequestVersionResolution.cs index 6720c85..1e444c9 100644 --- a/src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs +++ b/src/Governance/Abstractions/Resolution/Model/MutationRequestVersionResolution.cs @@ -1,6 +1,6 @@ -using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Resolution; +namespace ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; /// /// Represents the result of resolving a mutation request against the current state version. diff --git a/src/Governance/Abstractions/Resolution/MutationRequestVersionResolutionOutcome.cs b/src/Governance/Abstractions/Resolution/Model/MutationRequestVersionResolutionOutcome.cs similarity index 97% rename from src/Governance/Abstractions/Resolution/MutationRequestVersionResolutionOutcome.cs rename to src/Governance/Abstractions/Resolution/Model/MutationRequestVersionResolutionOutcome.cs index 67f8eb7..a84fe8a 100644 --- a/src/Governance/Abstractions/Resolution/MutationRequestVersionResolutionOutcome.cs +++ b/src/Governance/Abstractions/Resolution/Model/MutationRequestVersionResolutionOutcome.cs @@ -1,4 +1,4 @@ -namespace ModularityKit.Mutator.Governance.Abstractions.Resolution; +namespace ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; /// /// Describes the outcome of version-aware request resolution. diff --git a/src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs b/src/Governance/Abstractions/Resolution/Strategies/VersionedRequestResolutionStrategy.cs similarity index 95% rename from src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs rename to src/Governance/Abstractions/Resolution/Strategies/VersionedRequestResolutionStrategy.cs index d550484..500283f 100644 --- a/src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs +++ b/src/Governance/Abstractions/Resolution/Strategies/VersionedRequestResolutionStrategy.cs @@ -1,4 +1,4 @@ -namespace ModularityKit.Mutator.Governance.Abstractions.Resolution; +namespace ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; /// /// Strategy to apply when a mutation request is resolved against a newer state version than expected. diff --git a/src/Governance/Abstractions/Storage/IMutationRequestStore.cs b/src/Governance/Abstractions/Storage/IMutationRequestStore.cs index dd16210..e1a3562 100644 --- a/src/Governance/Abstractions/Storage/IMutationRequestStore.cs +++ b/src/Governance/Abstractions/Storage/IMutationRequestStore.cs @@ -1,5 +1,5 @@ -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; -using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Abstractions.Storage; diff --git a/src/Governance/README.md b/src/Governance/README.md index aa92834..41498b1 100644 --- a/src/Governance/README.md +++ b/src/Governance/README.md @@ -9,6 +9,7 @@ The core package stays responsible for direct mutation execution. Governance bui - **Mutation Requests** - model governed mutation submission as a durable request - **Pending Lifecycle** - represent requests that cannot execute immediately - **Decision History** - record approvals, rejections, cancellations, and other lifecycle transitions +- **Approval Workflow** - model request-level approval requirements and explicit approver actions - **Request Storage Contracts** - define a persistence seam for governance-oriented stores - **Runtime Lifecycle Management** - move requests through pending, approval, expiration, and execution transitions - **In-Memory Runtime Support** - provide lightweight request runtime services for development and tests @@ -20,6 +21,7 @@ The core package stays responsible for direct mutation execution. Governance bui The package defines governance-first abstractions under: - `Abstractions/Requests` +- `Abstractions/Approval` - `Abstractions/Lifecycle` - `Abstractions/Storage` - `Abstractions/Resolution` @@ -28,11 +30,18 @@ The package defines governance-first abstractions under: Key types: - `MutationRequest` +- `MutationApprovalRequirement` +- `MutationApprovalRequirementStatus` - `MutationRequestDecision` - `MutationRequestDecisionType` +- `MutationRequestDecisionCategory` +- `MutationRequestLifecycleDecisionType` +- `MutationRequestApprovalDecisionType` +- `MutationRequestVersionResolutionDecisionType` - `MutationRequestStatus` - `PendingMutationReason` - `IMutationRequestStore` +- `IMutationRequestApprovalWorkflowManager` - `IMutationRequestLifecycleManager` - `IMutationRequestVersionResolver` - `IMutationRequestVersionResolutionManager` @@ -40,19 +49,66 @@ Key types: - `MutationRequestVersionResolutionOutcome` - `VersionedRequestResolutionStrategy` - `MutationRequestAlreadyExistsException` +- `MutationApprovalRequirementNotFoundException` +- `InvalidMutationApprovalActionException` - `MutationRequestConcurrencyException` - `MutationRequestNotFoundException` - `InvalidMutationRequestTransitionException` +`Abstractions/Requests` is further split into: + +- `Requests/Model` +- `Requests/Decisions` + +`Abstractions/Approval` is further split into: + +- `Approval/Contracts` +- `Approval/Model` +- `Approval/Mapping` + +`Abstractions/Resolution` is further split into: + +- `Resolution/Contracts` +- `Resolution/Model` +- `Resolution/Strategies` + +`Abstractions/Lifecycle` is further split into: + +- `Lifecycle/Contracts` +- `Lifecycle/Model` + +`Abstractions/Exceptions` is further split into: + +- `Exceptions/Approval` +- `Exceptions/Lifecycle` +- `Exceptions/Storage` + ### Runtime The initial runtime layer currently provides: - `Runtime/Storage/InMemoryMutationRequestStore` +- `Runtime/Approval/MutationRequestApprovalWorkflowManager` - `Runtime/Lifecycle/MutationRequestLifecycleManager` - `Runtime/Resolution/MutationRequestVersionResolver` - `Runtime/Resolution/MutationRequestVersionResolutionManager` +`Runtime/Resolution` is further split into: + +- `Resolution/Evaluation` +- `Resolution/Execution` + +`Runtime/Lifecycle` is further split into: + +- `Lifecycle/Execution` +- `Lifecycle/Validation` +- `Lifecycle/State` + +`Runtime/Approval` is further split into: + +- `Approval/Execution` +- `Approval/State` + This keeps the first version small while leaving room for later persistence providers such as Entity Framework Core or PostgreSQL-backed governance stores. ## Relationship to Core diff --git a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalWorkflowManager.cs b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalWorkflowManager.cs new file mode 100644 index 0000000..ac1f530 --- /dev/null +++ b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalWorkflowManager.cs @@ -0,0 +1,218 @@ +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; +using ModularityKit.Mutator.Governance.Runtime.Approval.State; + +namespace ModularityKit.Mutator.Governance.Runtime.Approval.Execution; + +/// +/// Executes explicit approval and rejection actions for governed mutation requests. +/// +public sealed class MutationRequestApprovalWorkflowManager(IMutationRequestStore requestStore) + : IMutationRequestApprovalWorkflowManager +{ + private readonly IMutationRequestStore _requestStore = requestStore ?? throw new ArgumentNullException(nameof(requestStore)); + + /// + /// Approves a single request-level approval requirement and advances the request when all approvals are satisfied. + /// + public Task ApproveRequirement( + string requestId, + string approvalId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default) + { + return ApplyDecision( + requestId, + approvalId, + decisionContext, + reason, + metadata, + MutationRequestApprovalWorkflowState.ApplyApproval, + MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted), + finalizeApprovedRequest: true, + cancellationToken); + } + + /// + /// Rejects a single request-level approval requirement and terminates the request lifecycle. + /// + public Task RejectRequirement( + string requestId, + string approvalId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default) + { + return ApplyDecision( + requestId, + approvalId, + decisionContext, + reason, + metadata, + MutationRequestApprovalWorkflowState.ApplyRejection, + MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Rejected), + finalizeApprovedRequest: false, + cancellationToken); + } + + private async Task ApplyDecision( + string requestId, + string approvalId, + 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; + } +} diff --git a/src/Governance/Runtime/Approval/State/MutationRequestApprovalWorkflowState.cs b/src/Governance/Runtime/Approval/State/MutationRequestApprovalWorkflowState.cs new file mode 100644 index 0000000..e72187a --- /dev/null +++ b/src/Governance/Runtime/Approval/State/MutationRequestApprovalWorkflowState.cs @@ -0,0 +1,75 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Runtime.Approval.State; + +internal static class MutationRequestApprovalWorkflowState +{ + public static MutationApprovalRequirement ApplyApproval( + MutationApprovalRequirement requirement, + MutationContext decisionContext, + string? reason) + { + return requirement with + { + Status = MutationApprovalRequirementStatus.Approved, + DecidedAt = decisionContext.Timestamp, + DecisionContext = decisionContext, + DecisionReason = reason ?? decisionContext.Reason + }; + } + + public static MutationApprovalRequirement ApplyRejection( + MutationApprovalRequirement requirement, + MutationContext decisionContext, + string? reason) + { + return requirement with + { + Status = MutationApprovalRequirementStatus.Rejected, + DecidedAt = decisionContext.Timestamp, + DecisionContext = decisionContext, + DecisionReason = reason ?? decisionContext.Reason + }; + } + + public static IReadOnlyList Replace( + IReadOnlyList requirements, + MutationApprovalRequirement updated) + { + return requirements + .Select(requirement => requirement.ApprovalId == updated.ApprovalId ? updated : requirement) + .ToList(); + } + + public static MutationRequestDecision CreateApprovalDecision( + MutationRequestDecisionType decisionType, + MutationApprovalRequirement requirement, + MutationContext decisionContext, + string? reason, + IReadOnlyDictionary? metadata = null) + { + var mergedMetadata = new Dictionary + { + ["ApprovalId"] = requirement.ApprovalId, + ["ApproverId"] = requirement.ApproverId, + ["StepOrder"] = requirement.StepOrder + }; + + if (metadata is not null) + { + foreach (var pair in metadata) + { + mergedMetadata[pair.Key] = pair.Value; + } + } + + return MutationRequestDecision.Create( + decisionType, + decisionContext, + reason ?? decisionContext.Reason, + mergedMetadata); + } +} diff --git a/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleManager.cs b/src/Governance/Runtime/Lifecycle/Execution/MutationRequestLifecycleManager.cs similarity index 86% rename from src/Governance/Runtime/Lifecycle/MutationRequestLifecycleManager.cs rename to src/Governance/Runtime/Lifecycle/Execution/MutationRequestLifecycleManager.cs index 7644727..e7f0e33 100644 --- a/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleManager.cs +++ b/src/Governance/Runtime/Lifecycle/Execution/MutationRequestLifecycleManager.cs @@ -1,9 +1,12 @@ using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; -using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Contracts; +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.Lifecycle.State; -namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle; +namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle.Execution; /// /// Applies explicit runtime transitions to governed mutation requests and persists decision history. @@ -34,7 +37,7 @@ public Task MoveToPending( return _transitionExecutor.Execute( requestId, MutationRequestStatus.Pending, - MutationRequestDecisionType.Pending, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), decisionContext, reason, request => request with @@ -56,7 +59,7 @@ public Task Approve( return _transitionExecutor.Execute( requestId, MutationRequestStatus.Approved, - MutationRequestDecisionType.Approved, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), decisionContext, reason, request => request with @@ -77,7 +80,7 @@ public Task Reject( return _transitionExecutor.Execute( requestId, MutationRequestStatus.Rejected, - MutationRequestDecisionType.Rejected, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected), decisionContext, reason, MutationRequestLifecycleState.ClearPendingState, @@ -95,7 +98,7 @@ public Task Cancel( return _transitionExecutor.Execute( requestId, MutationRequestStatus.Canceled, - MutationRequestDecisionType.Canceled, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Canceled), decisionContext, reason, MutationRequestLifecycleState.ClearPendingState, @@ -113,7 +116,7 @@ public Task Expire( return _transitionExecutor.Execute( requestId, MutationRequestStatus.Expired, - MutationRequestDecisionType.Expired, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Expired), decisionContext, reason, MutationRequestLifecycleState.ClearPendingState, @@ -172,7 +175,7 @@ public Task Supersede( return _transitionExecutor.Execute( requestId, MutationRequestStatus.Superseded, - MutationRequestDecisionType.Superseded, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Superseded), decisionContext, reason ?? $"Superseded by request '{supersedingRequestId}'.", MutationRequestLifecycleState.ClearPendingState, @@ -190,7 +193,7 @@ public Task MarkExecuted( return _transitionExecutor.Execute( requestId, MutationRequestStatus.Executed, - MutationRequestDecisionType.Executed, + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), decisionContext, reason, MutationRequestLifecycleState.ClearPendingState, diff --git a/src/Governance/Runtime/Lifecycle/MutationRequestTransitionExecutor.cs b/src/Governance/Runtime/Lifecycle/Execution/MutationRequestTransitionExecutor.cs similarity index 82% rename from src/Governance/Runtime/Lifecycle/MutationRequestTransitionExecutor.cs rename to src/Governance/Runtime/Lifecycle/Execution/MutationRequestTransitionExecutor.cs index f0388fe..eba88b6 100644 --- a/src/Governance/Runtime/Lifecycle/MutationRequestTransitionExecutor.cs +++ b/src/Governance/Runtime/Lifecycle/Execution/MutationRequestTransitionExecutor.cs @@ -1,10 +1,14 @@ 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.Exceptions.Lifecycle; +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.Lifecycle.State; +using ModularityKit.Mutator.Governance.Runtime.Lifecycle.Validation; -namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle; +namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle.Execution; /// /// Executes a single guarded lifecycle transition for a governed mutation request. diff --git a/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleState.cs b/src/Governance/Runtime/Lifecycle/State/MutationRequestLifecycleState.cs similarity index 89% rename from src/Governance/Runtime/Lifecycle/MutationRequestLifecycleState.cs rename to src/Governance/Runtime/Lifecycle/State/MutationRequestLifecycleState.cs index 9e63b70..46a40d3 100644 --- a/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleState.cs +++ b/src/Governance/Runtime/Lifecycle/State/MutationRequestLifecycleState.cs @@ -1,6 +1,6 @@ -using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle; +namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle.State; /// /// Provides shared state transformations and metadata helpers for lifecycle transitions. diff --git a/src/Governance/Runtime/Lifecycle/MutationRequestTransitionValidator.cs b/src/Governance/Runtime/Lifecycle/Validation/MutationRequestTransitionValidator.cs similarity index 90% rename from src/Governance/Runtime/Lifecycle/MutationRequestTransitionValidator.cs rename to src/Governance/Runtime/Lifecycle/Validation/MutationRequestTransitionValidator.cs index bdeec57..6e9c1c7 100644 --- a/src/Governance/Runtime/Lifecycle/MutationRequestTransitionValidator.cs +++ b/src/Governance/Runtime/Lifecycle/Validation/MutationRequestTransitionValidator.cs @@ -1,8 +1,8 @@ using System.Collections.Frozen; -using ModularityKit.Mutator.Governance.Abstractions.Exceptions; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle; +namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle.Validation; /// /// Validates whether a governed mutation request can move from one lifecycle status to another. diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionEvaluation.cs b/src/Governance/Runtime/Resolution/Evaluation/MutationRequestVersionEvaluation.cs similarity index 89% rename from src/Governance/Runtime/Resolution/MutationRequestVersionEvaluation.cs rename to src/Governance/Runtime/Resolution/Evaluation/MutationRequestVersionEvaluation.cs index c7ab2bc..7a488f2 100644 --- a/src/Governance/Runtime/Resolution/MutationRequestVersionEvaluation.cs +++ b/src/Governance/Runtime/Resolution/Evaluation/MutationRequestVersionEvaluation.cs @@ -1,4 +1,4 @@ -namespace ModularityKit.Mutator.Governance.Runtime.Resolution; +namespace ModularityKit.Mutator.Governance.Runtime.Resolution.Evaluation; /// /// Describes how a governed request version compares to the currently observed state version. diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionEvaluator.cs b/src/Governance/Runtime/Resolution/Evaluation/MutationRequestVersionEvaluator.cs similarity index 89% rename from src/Governance/Runtime/Resolution/MutationRequestVersionEvaluator.cs rename to src/Governance/Runtime/Resolution/Evaluation/MutationRequestVersionEvaluator.cs index 068a7da..da7c6ca 100644 --- a/src/Governance/Runtime/Resolution/MutationRequestVersionEvaluator.cs +++ b/src/Governance/Runtime/Resolution/Evaluation/MutationRequestVersionEvaluator.cs @@ -1,6 +1,6 @@ -using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Runtime.Resolution; +namespace ModularityKit.Mutator.Governance.Runtime.Resolution.Evaluation; /// /// Evaluates whether a governed request still matches the currently observed state version. diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionFactory.cs b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionFactory.cs similarity index 85% rename from src/Governance/Runtime/Resolution/MutationRequestVersionResolutionFactory.cs rename to src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionFactory.cs index a2f95fe..829aa56 100644 --- a/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionFactory.cs +++ b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionFactory.cs @@ -1,9 +1,11 @@ using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; -using ModularityKit.Mutator.Governance.Abstractions.Requests; -using ModularityKit.Mutator.Governance.Abstractions.Resolution; +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.Runtime.Resolution.Evaluation; -namespace ModularityKit.Mutator.Governance.Runtime.Resolution; +namespace ModularityKit.Mutator.Governance.Runtime.Resolution.Execution; /// /// Builds concrete version-resolution outcomes and updated request snapshots for governance runtime flows. @@ -19,7 +21,7 @@ public static MutationRequestVersionResolution BuildValidated( MutationContext resolutionContext) { var validatedDecision = MutationRequestDecision.Create( - MutationRequestDecisionType.VersionValidated, + MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.Validated), resolutionContext, reason: MutationRequestVersionResolutionState.BuildValidatedReason( evaluation.ExpectedStateVersion, @@ -47,7 +49,7 @@ public static MutationRequestVersionResolution BuildRejectedAsStale( MutationContext resolutionContext) { var decision = MutationRequestDecision.Create( - MutationRequestDecisionType.RejectedAsStale, + MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RejectedAsStale), resolutionContext, reason: MutationRequestVersionResolutionState.BuildStaleReason( evaluation.ExpectedStateVersion!, @@ -80,7 +82,7 @@ public static MutationRequestVersionResolution BuildRenewedApprovalRequired( MutationContext resolutionContext) { var decision = MutationRequestDecision.Create( - MutationRequestDecisionType.RenewedApprovalRequired, + MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RenewedApprovalRequired), resolutionContext, reason: MutationRequestVersionResolutionState.BuildStaleReason( evaluation.ExpectedStateVersion!, @@ -113,7 +115,7 @@ public static MutationRequestVersionResolution BuildRevalidationRequired( MutationContext resolutionContext) { var decision = MutationRequestDecision.Create( - MutationRequestDecisionType.RevalidationRequired, + MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RevalidationRequired), resolutionContext, reason: MutationRequestVersionResolutionState.BuildStaleReason( evaluation.ExpectedStateVersion!, diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionManager.cs b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionManager.cs similarity index 85% rename from src/Governance/Runtime/Resolution/MutationRequestVersionResolutionManager.cs rename to src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionManager.cs index b7d48cd..23d367f 100644 --- a/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionManager.cs +++ b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionManager.cs @@ -1,9 +1,11 @@ using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Governance.Abstractions.Exceptions; -using ModularityKit.Mutator.Governance.Abstractions.Resolution; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Contracts; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; using ModularityKit.Mutator.Governance.Abstractions.Storage; -namespace ModularityKit.Mutator.Governance.Runtime.Resolution; +namespace ModularityKit.Mutator.Governance.Runtime.Resolution.Execution; /// /// Resolves governed requests against the current state version and persists the resulting resolution outcome. diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionState.cs b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs similarity index 91% rename from src/Governance/Runtime/Resolution/MutationRequestVersionResolutionState.cs rename to src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs index 25414f1..95b425e 100644 --- a/src/Governance/Runtime/Resolution/MutationRequestVersionResolutionState.cs +++ b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs @@ -1,7 +1,9 @@ -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; -using ModularityKit.Mutator.Governance.Abstractions.Requests; +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.Runtime.Resolution.Evaluation; -namespace ModularityKit.Mutator.Governance.Runtime.Resolution; +namespace ModularityKit.Mutator.Governance.Runtime.Resolution.Execution; /// /// Provides shared state transformations and metadata helpers for version-aware governance resolution. diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolver.cs similarity index 77% rename from src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs rename to src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolver.cs index 5a8d066..21e216d 100644 --- a/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs +++ b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolver.cs @@ -1,9 +1,12 @@ using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; -using ModularityKit.Mutator.Governance.Abstractions.Requests; -using ModularityKit.Mutator.Governance.Abstractions.Resolution; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Contracts; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; +using ModularityKit.Mutator.Governance.Runtime.Resolution.Evaluation; -namespace ModularityKit.Mutator.Governance.Runtime.Resolution; +namespace ModularityKit.Mutator.Governance.Runtime.Resolution.Execution; /// /// Applies explicit version-aware resolution semantics to governed mutation requests. diff --git a/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs index 74d40c6..dec9c9e 100644 --- a/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs +++ b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs @@ -1,7 +1,7 @@ -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; -using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Storage; -using ModularityKit.Mutator.Governance.Abstractions.Exceptions; namespace ModularityKit.Mutator.Governance.Runtime.Storage;