Skip to content

Commit 6c62c1d

Browse files
committed
Feat: Add PolicyComposition solution and test policies
1 parent a238615 commit 6c62c1d

13 files changed

Lines changed: 590 additions & 1 deletion

Docs/Roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ Why this matters:
201201

202202
### 2. Governance-Aware Policy Composition
203203

204-
Add composition primitives for complex policy sets.
204+
Composition primitives for complex policy sets are now available in the core policy abstractions.
205205

206206
Scope:
207207

ModularityKit.Mutator.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<Project Path="Examples/Core/BillingQuotas/BillingQuotas.csproj" />
99
<Project Path="Examples/Core/FeatureFlags/FeatureFlags.csproj" />
1010
<Project Path="Examples/Core/IamRoles/IamRoles.csproj" />
11+
<Project Path="Examples/Core/PolicyComposition/PolicyComposition.csproj" />
1112
<Project Path="Examples/Core/WorkflowApprovals/WorkflowApprovals.csproj" />
1213
<Project Path="Examples/Governance/ApprovalWorkflow/ApprovalWorkflow.csproj" />
1314
<Project Path="Examples/Governance/GovernedExecution/GovernedExecution.csproj" />
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
using ModularityKit.Mutator.Abstractions.Effects;
2+
using ModularityKit.Mutator.Abstractions.Engine;
3+
using ModularityKit.Mutator.Abstractions.Exceptions;
4+
using ModularityKit.Mutator.Abstractions.Policies;
5+
using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples;
6+
using ModularityKit.Mutator.Tests.TestSupport.Mutations;
7+
using ModularityKit.Mutator.Tests.TestSupport.Policies.Composition;
8+
using Xunit;
9+
10+
namespace ModularityKit.Mutator.Tests.Runtime.Policies;
11+
12+
public sealed class PolicyCompositionTests
13+
{
14+
[Fact]
15+
public async Task AllOf_merges_modifications_side_effects_and_metadata()
16+
{
17+
var composed = PolicyComposition.AllOf(
18+
"FeatureFlagGovernance",
19+
[
20+
new StateAndSideEffectPolicy(),
21+
new SideEffectsAndMetadataPolicy()
22+
],
23+
priority: 500,
24+
description: "Composed governance rules for feature flag changes.");
25+
26+
var decision = await composed.EvaluateAsync(new PolicySampleMutation(), new PolicySampleState("initial"));
27+
28+
Assert.True(decision.IsAllowed);
29+
Assert.Equal("governed", GetState(decision).Value);
30+
Assert.Equal(2, GetSideEffects(decision).Count());
31+
Assert.Equal("FeatureFlagGovernance", decision.PolicyName);
32+
Assert.Equal("AllOf", decision.Metadata!["PolicyComposition.Mode"]);
33+
Assert.Equal("state-policy", decision.Metadata!["owner"]);
34+
Assert.Equal("state-policy", decision.Metadata!["source"]);
35+
}
36+
37+
[Fact]
38+
public async Task AllOf_merges_requirements_and_metadata()
39+
{
40+
var composed = PolicyComposition.AllOf(
41+
"ApprovalGate",
42+
[
43+
new ApprovalPolicy(),
44+
new MetadataPolicy()
45+
],
46+
priority: 100);
47+
48+
var decision = await composed.EvaluateAsync(
49+
new PolicySampleMutation(),
50+
new PolicySampleState("initial")
51+
);
52+
53+
Assert.False(decision.IsAllowed);
54+
Assert.Single(decision.Requirements!);
55+
Assert.Equal("Approval", decision.Requirements![0].Type);
56+
Assert.Equal(PolicyDecisionSeverity.Error, decision.Severity);
57+
Assert.Equal("ApprovalGate", decision.PolicyName);
58+
Assert.Equal("AllOf", decision.Metadata!["PolicyComposition.Mode"]);
59+
Assert.Equal("compliance", decision.Metadata!["team"]);
60+
Assert.Equal("platform", decision.Metadata!["owner"]);
61+
Assert.Equal(["ApprovalPolicy", "MetadataPolicy"], (string[])decision.Metadata["PolicyComposition.BlockingPolicies"]);
62+
}
63+
64+
[Fact]
65+
public async Task AnyOf_uses_only_allowed_branches_when_one_branch_succeeds()
66+
{
67+
var composed = PolicyComposition.AnyOf(
68+
"AlternativeAllow",
69+
[
70+
new BlockingPolicy(),
71+
new AllowedStatePolicy()
72+
],
73+
priority: 100);
74+
75+
var decision = await composed.EvaluateAsync(new PolicySampleMutation(), new PolicySampleState("initial"));
76+
77+
Assert.True(decision.IsAllowed);
78+
Assert.NotNull(decision.Modifications);
79+
Assert.Equal("AlternativeAllow", decision.PolicyName);
80+
Assert.Equal("AnyOf", decision.Metadata!["PolicyComposition.Mode"]);
81+
Assert.Equal(["AllowedStatePolicy"], (string[])decision.Metadata["PolicyComposition.WinningPolicies"]);
82+
Assert.Equal(["BlockingPolicy"], (string[])decision.Metadata["PolicyComposition.BlockingPolicies"]);
83+
Assert.Equal("allowed", GetState(decision).Value);
84+
}
85+
86+
[Fact]
87+
public async Task Priority_composition_returns_first_decisive_higher_priority_policy()
88+
{
89+
var composed = PolicyComposition.Priority(
90+
"PriorityGate",
91+
[
92+
new LowPriorityStatePolicy(),
93+
new HighPriorityStatePolicy()
94+
],
95+
priority: 100);
96+
97+
var decision = await composed.EvaluateAsync(new PolicySampleMutation(), new PolicySampleState("initial"));
98+
99+
Assert.True(decision.IsAllowed);
100+
Assert.Equal("PriorityGate", decision.PolicyName);
101+
Assert.Equal("high", GetState(decision).Value);
102+
Assert.Single(GetSideEffects(decision));
103+
Assert.Equal("high", decision.Metadata!["selectedPolicy"]);
104+
}
105+
106+
[Fact]
107+
public async Task AllOf_detects_conflicting_mutation_result_modifications()
108+
{
109+
var composed = PolicyComposition.AllOf(
110+
"ConflictingGate",
111+
[
112+
new StatePolicy("First", "one"),
113+
new StatePolicy("Second", "two")
114+
],
115+
priority: 100);
116+
117+
var exception = await Assert.ThrowsAsync<PolicyCompositionConflictException>(() =>
118+
composed.EvaluateAsync(new PolicySampleMutation(), new PolicySampleState("initial")));
119+
120+
Assert.Equal("ConflictingGate", exception.CompositionName);
121+
Assert.Equal("State", exception.ConflictKey);
122+
Assert.Equal(["First", "Second"], exception.PolicyNames);
123+
}
124+
125+
private static PolicySampleState GetState(PolicyDecision decision)
126+
=> (PolicySampleState)decision.Modifications!["State"];
127+
128+
private static IEnumerable<SideEffect> GetSideEffects(PolicyDecision decision)
129+
=> (IEnumerable<SideEffect>)decision.Modifications!["SideEffects"];
130+
131+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using ModularityKit.Mutator.Abstractions.Engine;
2+
using ModularityKit.Mutator.Abstractions.Policies;
3+
using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples;
4+
5+
namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition;
6+
7+
/// <summary>
8+
/// Test policy that allows the mutation and replaces the sample state value.
9+
/// </summary>
10+
/// <remarks>
11+
/// Used by policy composition tests to verify that state modifications from
12+
/// allowed policies are propagated correctly through composed decisions.
13+
/// </remarks>
14+
internal sealed class AllowedStatePolicy : IMutationPolicy<PolicySampleState>
15+
{
16+
/// <summary>
17+
/// Gets the policy name.
18+
/// </summary>
19+
public string Name => "AllowedStatePolicy";
20+
21+
/// <summary>
22+
/// Gets the evaluation priority.
23+
/// </summary>
24+
public int Priority => 100;
25+
26+
/// <summary>
27+
/// Gets the policy description.
28+
/// </summary>
29+
public string Description => "Applies the allowed branch.";
30+
31+
/// <summary>
32+
/// Produces an allowed decision containing a modified sample state.
33+
/// </summary>
34+
/// <param name="mutation">The mutation being evaluated.</param>
35+
/// <param name="state">The current sample state.</param>
36+
/// <returns>An allowed decision with replacement state modification.</returns>
37+
public PolicyDecision Evaluate(IMutation<PolicySampleState> mutation, PolicySampleState state)
38+
=> new()
39+
{
40+
IsAllowed = true,
41+
PolicyName = Name,
42+
Modifications = new Dictionary<string, object>
43+
{
44+
["State"] = new PolicySampleState(Value: "allowed")
45+
}
46+
};
47+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using ModularityKit.Mutator.Abstractions.Engine;
2+
using ModularityKit.Mutator.Abstractions.Policies;
3+
using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples;
4+
5+
namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition;
6+
7+
/// <summary>
8+
/// Test policy that blocks execution and requires explicit approval.
9+
/// </summary>
10+
/// <remarks>
11+
/// Used by policy composition tests to verify approval requirements,
12+
/// blocking decisions, and requirement aggregation.
13+
/// </remarks>
14+
internal sealed class ApprovalPolicy : IMutationPolicy<PolicySampleState>
15+
{
16+
/// <summary>
17+
/// Gets the policy name.
18+
/// </summary>
19+
public string Name => "ApprovalPolicy";
20+
21+
/// <summary>
22+
/// Gets the evaluation priority.
23+
/// </summary>
24+
public int Priority => 200;
25+
26+
/// <summary>
27+
/// Gets the policy description.
28+
/// </summary>
29+
public string Description => "Requires signoff for sensitive work.";
30+
31+
/// <summary>
32+
/// Produces a blocking decision that requires explicit approval.
33+
/// </summary>
34+
/// <param name="mutation">The mutation being evaluated.</param>
35+
/// <param name="state">The current sample state.</param>
36+
/// <returns>A blocking decision with an approval requirement.</returns>
37+
public PolicyDecision Evaluate(IMutation<PolicySampleState> mutation, PolicySampleState state)
38+
=> new()
39+
{
40+
IsAllowed = false,
41+
PolicyName = Name,
42+
Severity = PolicyDecisionSeverity.Error,
43+
Reason = "Sensitive change requires approval.",
44+
Requirements = [PolicyRequirement.Approval("approver-a", "Sensitive change requires approval.")]
45+
};
46+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using ModularityKit.Mutator.Abstractions.Engine;
2+
using ModularityKit.Mutator.Abstractions.Policies;
3+
using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples;
4+
5+
namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition;
6+
7+
/// <summary>
8+
/// Test policy that unconditionally blocks mutation execution.
9+
/// </summary>
10+
/// <remarks>
11+
/// Used by policy composition tests to verify blocking behavior,
12+
/// precedence rules, and denial propagation.
13+
/// </remarks>
14+
internal sealed class BlockingPolicy : IMutationPolicy<PolicySampleState>
15+
{
16+
/// <summary>
17+
/// Gets the policy name.
18+
/// </summary>
19+
public string Name => "BlockingPolicy";
20+
21+
/// <summary>
22+
/// Gets the evaluation priority.
23+
/// </summary>
24+
public int Priority => 400;
25+
26+
/// <summary>
27+
/// Gets the policy description.
28+
/// </summary>
29+
public string Description => "Rejects the mutation.";
30+
31+
/// <summary>
32+
/// Produces a blocking policy decision.
33+
/// </summary>
34+
/// <param name="mutation">The mutation being evaluated.</param>
35+
/// <param name="state">The current sample state.</param>
36+
/// <returns>A denial decision.</returns>
37+
public PolicyDecision Evaluate(IMutation<PolicySampleState> mutation, PolicySampleState state)
38+
=> PolicyDecision.Deny("Blocked by policy.", Name);
39+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using ModularityKit.Mutator.Abstractions.Effects;
2+
using ModularityKit.Mutator.Abstractions.Engine;
3+
using ModularityKit.Mutator.Abstractions.Policies;
4+
using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples;
5+
6+
namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition;
7+
8+
/// <summary>
9+
/// Test policy that contributes high priority state modification.
10+
/// </summary>
11+
/// <remarks>
12+
/// Used by policy composition tests to verify priority selection,
13+
/// metadata propagation, and side effect aggregation.
14+
/// </remarks>
15+
internal sealed class HighPriorityStatePolicy : IMutationPolicy<PolicySampleState>
16+
{
17+
/// <summary>
18+
/// Gets the policy name.
19+
/// </summary>
20+
public string Name => "HighPriorityStatePolicy";
21+
22+
/// <summary>
23+
/// Gets the evaluation priority.
24+
/// </summary>
25+
public int Priority => 500;
26+
27+
/// <summary>
28+
/// Gets the policy description.
29+
/// </summary>
30+
public string Description => "Higher priority branch.";
31+
32+
/// <summary>
33+
/// Produces an allowed decision with a high-priority state modification,
34+
/// metadata, and an audit side effect.
35+
/// </summary>
36+
/// <param name="mutation">The mutation being evaluated.</param>
37+
/// <param name="state">The current sample state.</param>
38+
/// <returns>An allowed decision with state, metadata, and side effects.</returns>
39+
public PolicyDecision Evaluate(IMutation<PolicySampleState> mutation, PolicySampleState state)
40+
=> new()
41+
{
42+
IsAllowed = true,
43+
PolicyName = Name,
44+
Modifications = new Dictionary<string, object>
45+
{
46+
["State"] = new PolicySampleState(Value: "high"),
47+
["SideEffect"] = SideEffect.Create("audit", "High priority branch selected.")
48+
},
49+
Metadata = new Dictionary<string, object>
50+
{
51+
["selectedPolicy"] = "high"
52+
}
53+
};
54+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using ModularityKit.Mutator.Abstractions.Engine;
2+
using ModularityKit.Mutator.Abstractions.Policies;
3+
using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples;
4+
5+
namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition;
6+
7+
/// <summary>
8+
/// Test policy that contributes low priority state modification.
9+
/// </summary>
10+
/// <remarks>
11+
/// Used by policy composition tests to verify deterministic priority ordering
12+
/// and metadata conflict handling.
13+
/// </remarks>
14+
internal sealed class LowPriorityStatePolicy : IMutationPolicy<PolicySampleState>
15+
{
16+
/// <summary>
17+
/// Gets the policy name.
18+
/// </summary>
19+
public string Name => "LowPriorityStatePolicy";
20+
21+
/// <summary>
22+
/// Gets the evaluation priority.
23+
/// </summary>
24+
public int Priority => 100;
25+
26+
/// <summary>
27+
/// Gets the policy description.
28+
/// </summary>
29+
public string Description => "Lower priority branch.";
30+
31+
/// <summary>
32+
/// Produces an allowed decision with a low-priority state modification.
33+
/// </summary>
34+
/// <param name="mutation">The mutation being evaluated.</param>
35+
/// <param name="state">The current sample state.</param>
36+
/// <returns>An allowed decision with state and metadata.</returns>
37+
public PolicyDecision Evaluate(IMutation<PolicySampleState> mutation, PolicySampleState state)
38+
=> new()
39+
{
40+
IsAllowed = true,
41+
PolicyName = Name,
42+
Modifications = new Dictionary<string, object>
43+
{
44+
["State"] = new PolicySampleState(Value: "low")
45+
},
46+
Metadata = new Dictionary<string, object>
47+
{
48+
["selectedPolicy"] = Name
49+
}
50+
};
51+
}

0 commit comments

Comments
 (0)