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