Skip to content

Commit 27f4709

Browse files
authored
Feat: add policy composition primitives and examples (#83)
2 parents 3f644ab + 6c62c1d commit 27f4709

43 files changed

Lines changed: 2405 additions & 6 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using FeatureFlags.State;
2+
using ModularityKit.Mutator.Abstractions.Policies;
3+
4+
namespace FeatureFlags.Policies;
5+
6+
/// <summary>
7+
/// Reusable policy compositions for sensitive feature flag changes.
8+
/// </summary>
9+
internal static class FeatureFlagGovernancePolicies
10+
{
11+
/// <summary>
12+
/// Composed governance policy set for critical feature flag changes.
13+
/// </summary>
14+
public static IMutationPolicy<FeatureFlagsState> CriticalChanges() =>
15+
PolicyComposition.AllOf(
16+
name: "CriticalFeatureFlagGovernance",
17+
policies:
18+
[
19+
new BusinessHoursPolicy(),
20+
new RequireTwoManApprovalPolicy()
21+
],
22+
priority: 200,
23+
description: "Requires business-hours execution and two-man approval for critical feature flag changes.");
24+
}

Examples/Core/FeatureFlags/Program.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ private static async Task Main()
1717
var provider = services.BuildServiceProvider();
1818
var engine = provider.GetRequiredService<IMutationEngine>();
1919

20-
//engine.RegisterPolicy(new BusinessHoursPolicy());
21-
engine.RegisterPolicy(new RequireTwoManApprovalPolicy());
20+
engine.RegisterPolicy(FeatureFlagGovernancePolicies.CriticalChanges());
2221

2322
Console.WriteLine("=== ModularityKit.Mutators - Complete Example ===\n");
2423

@@ -41,4 +40,4 @@ private static async Task Main()
4140
Console.WriteLine($" Median execution time: {stats.MedianExecutionTime.TotalMilliseconds:F2} ms");
4241
Console.WriteLine($" P95 execution time: {stats.P95ExecutionTime.TotalMilliseconds:F2} ms");
4342
}
44-
}
43+
}

Examples/Core/FeatureFlags/README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The example covers three workflows:
3131
- [`Mutations/DisableFeatureMutation.cs`](Mutations/DisableFeatureMutation.cs)
3232
- [`Policies/BusinessHoursPolicy.cs`](Policies/BusinessHoursPolicy.cs)
3333
- [`Policies/RequireTwoManApprovalPolicy.cs`](Policies/RequireTwoManApprovalPolicy.cs)
34+
- [`Policies/FeatureFlagGovernancePolicies.cs`](Policies/FeatureFlagGovernancePolicies.cs)
3435
- [`Scenarios/EnableNewCheckoutScenario.cs`](Scenarios/EnableNewCheckoutScenario.cs)
3536
- [`Scenarios/DisableLegacyCheckoutScenario.cs`](Scenarios/DisableLegacyCheckoutScenario.cs)
3637
- [`Scenarios/BatchFeatureToggleScenario.cs`](Scenarios/BatchFeatureToggleScenario.cs)
@@ -41,7 +42,7 @@ The example covers three workflows:
4142

4243
1. registers the engine with strict options
4344
2. resolves `IMutationEngine`
44-
3. registers `RequireTwoManApprovalPolicy`
45+
3. registers the composed `CriticalFeatureFlagGovernance` policy set
4546
4. runs the example scenarios
4647
5. prints history for the main state
4748
6. prints engine statistics
@@ -84,6 +85,16 @@ It demonstrates:
8485

8586
Use it as reference if you want to restrict rollout windows.
8687

88+
### Composed governance set
89+
90+
[`FeatureFlagGovernancePolicies.CriticalChanges`](Policies/FeatureFlagGovernancePolicies.cs) combines business-hours restrictions and two-man approval into one reusable policy set.
91+
92+
It demonstrates:
93+
94+
- `PolicyComposition.AllOf(...)`
95+
- explicit policy merge behavior
96+
- registering one composed policy instead of multiple hand-wired policy classes
97+
8798
## Scenarios
8899

89100
### Enable new checkout
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using ModularityKit.Mutator.Abstractions.Changes;
2+
using ModularityKit.Mutator.Abstractions.Context;
3+
using ModularityKit.Mutator.Abstractions.Engine;
4+
using ModularityKit.Mutator.Abstractions.Results;
5+
using PolicyComposition.State;
6+
7+
namespace PolicyComposition.Mutations;
8+
9+
/// <summary>
10+
/// Submits a release and carries the governance metadata consumed by the policies.
11+
/// </summary>
12+
/// <remarks>
13+
/// The mutation itself only moves the release into the submitted stage. The
14+
/// interesting governance behavior lives in the composed policies, which read the
15+
/// approval count, emergency flag, and target environment from the mutation
16+
/// context metadata.
17+
/// </remarks>
18+
internal sealed class SubmitReleaseMutation(
19+
string releaseName,
20+
int approvals,
21+
bool emergency,
22+
string environment) : MutationBase<ReleaseGateState>(
23+
CreateIntent(
24+
operationName: "SubmitRelease",
25+
category: "ReleaseGovernance",
26+
description: "Submit a release through composed governance policies"),
27+
MutationContext.User("release-manager", "Release Manager", "Release composition example")
28+
with
29+
{
30+
StateId = releaseName,
31+
Metadata = new Dictionary<string, object>
32+
{
33+
["approvals"] = approvals,
34+
["emergency"] = emergency,
35+
["environment"] = environment
36+
}
37+
})
38+
{
39+
/// <summary>
40+
/// Marks the release as submitted before policy composition evaluates it.
41+
/// </summary>
42+
/// <param name="state">The current release state.</param>
43+
/// <returns>A mutation result that moves the release into the submitted stage.</returns>
44+
public override MutationResult<ReleaseGateState> Apply(ReleaseGateState state)
45+
=> Success(
46+
state with { Stage = "Submitted" },
47+
StateChange.Modified("Stage", state.Stage, "Submitted"));
48+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using ModularityKit.Mutator.Abstractions.Effects;
2+
using ModularityKit.Mutator.Abstractions.Engine;
3+
using ModularityKit.Mutator.Abstractions.Policies;
4+
using PolicyComposition.State;
5+
6+
namespace PolicyComposition.Policies.Approval;
7+
8+
/// <summary>
9+
/// Appends audit trail information to an already allowed approval gate decision.
10+
/// </summary>
11+
/// <remarks>
12+
/// This policy does not block or change the release state. It exists to show how
13+
/// composed policies can contribute side effects and metadata independently of
14+
/// the policy that made the main business decision.
15+
/// </remarks>
16+
internal sealed class AddAuditTrailPolicy : IMutationPolicy<ReleaseGateState>
17+
{
18+
/// <summary>
19+
/// Stable policy identifier used in composition metadata and diagnostics.
20+
/// </summary>
21+
public string Name => "AddAuditTrail";
22+
23+
/// <summary>
24+
/// Medium priority so this policy can be grouped with the approval gate it decorates.
25+
/// </summary>
26+
public int Priority => 200;
27+
28+
/// <summary>
29+
/// Describes the audit trail side effect this policy adds to the composed result.
30+
/// </summary>
31+
public string Description => "Adds audit metadata and a notification side effect.";
32+
33+
/// <summary>
34+
/// Emits one audit side effect and a simple metadata flag.
35+
/// </summary>
36+
/// <param name="mutation">The mutation being evaluated.</param>
37+
/// <param name="state">The current release state.</param>
38+
/// <returns>An allowed decision with audit metadata and a single side effect.</returns>
39+
public PolicyDecision Evaluate(IMutation<ReleaseGateState> mutation, ReleaseGateState state)
40+
=> new()
41+
{
42+
IsAllowed = true,
43+
PolicyName = Name,
44+
Modifications = new Dictionary<string, object>
45+
{
46+
["SideEffects"] = new[]
47+
{
48+
SideEffect.Create("audit", $"Release {state.ReleaseName} passed the composed approval gate.")
49+
}
50+
},
51+
Metadata = new Dictionary<string, object>
52+
{
53+
["auditTrail"] = "enabled"
54+
}
55+
};
56+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using ModularityKit.Mutator.Abstractions.Effects;
2+
using ModularityKit.Mutator.Abstractions.Engine;
3+
using ModularityKit.Mutator.Abstractions.Policies;
4+
using PolicyComposition.State;
5+
6+
namespace PolicyComposition.Policies.Approval;
7+
8+
/// <summary>
9+
/// Blocks release promotion until the expected approval threshold is present.
10+
/// </summary>
11+
/// <remarks>
12+
/// The policy reads the approval count from mutation metadata, so the example can
13+
/// show how governance input travels alongside the mutation itself.
14+
/// When the threshold is met, the policy marks the release as approved and emits
15+
/// an audit side effect. When it is not met, the policy returns a requirement and
16+
/// error severity so the composed result can surface the missing approval.
17+
/// </remarks>
18+
internal sealed class RequireApprovalsPolicy : IMutationPolicy<ReleaseGateState>
19+
{
20+
/// <summary>
21+
/// Stable policy identifier used in diagnostics and composition metadata.
22+
/// </summary>
23+
public string Name => "RequireApprovals";
24+
25+
/// <summary>
26+
/// Higher than the audit only policy, so approval gating is evaluated first.
27+
/// </summary>
28+
public int Priority => 300;
29+
30+
/// <summary>
31+
/// Explains the minimum-approval requirement that this policy enforces.
32+
/// </summary>
33+
public string Description => "Requires at least two approvals before the release can proceed.";
34+
35+
/// <summary>
36+
/// Reads the approval count from mutation metadata and either allows or blocks the release.
37+
/// </summary>
38+
/// <param name="mutation">The mutation carrying government metadata.</param>
39+
/// <param name="state">The current release state.</param>
40+
/// <returns>
41+
/// An allowed decision when approvals are enough, otherwise a blocking
42+
/// decision with an approval requirement and error severity.
43+
/// </returns>
44+
public PolicyDecision Evaluate(IMutation<ReleaseGateState> mutation, ReleaseGateState state)
45+
{
46+
var approvals = GetInt32(mutation.Context.Metadata, "approvals");
47+
48+
return approvals >= 2
49+
? new PolicyDecision
50+
{
51+
IsAllowed = true,
52+
PolicyName = Name,
53+
Modifications = new Dictionary<string, object>
54+
{
55+
["State"] = state with { Stage = "Approved" },
56+
["SideEffect"] = SideEffect.Create("audit", $"Release approved with {approvals} approvals.")
57+
},
58+
Metadata = new Dictionary<string, object>
59+
{
60+
["approvalCount"] = approvals
61+
}
62+
}
63+
: new PolicyDecision
64+
{
65+
IsAllowed = false,
66+
PolicyName = Name,
67+
Severity = PolicyDecisionSeverity.Error,
68+
Reason = $"Release requires at least two approvals; found {approvals}.",
69+
Requirements =
70+
[
71+
PolicyRequirement.Approval("release-manager", "Two approvals are required before promotion.")
72+
],
73+
Metadata = new Dictionary<string, object>
74+
{
75+
["approvalCount"] = approvals
76+
}
77+
};
78+
}
79+
80+
private static int GetInt32(IReadOnlyDictionary<string, object> metadata, string key)
81+
=> metadata.TryGetValue(key, out var value) && value is int number ? number : 0;
82+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using ModularityKit.Mutator.Abstractions.Effects;
2+
using ModularityKit.Mutator.Abstractions.Engine;
3+
using ModularityKit.Mutator.Abstractions.Policies;
4+
using PolicyComposition.State;
5+
6+
namespace PolicyComposition.Policies.Deployment;
7+
8+
/// <summary>
9+
/// Supplies the fallback deployment path for non production releases.
10+
/// </summary>
11+
/// <remarks>
12+
/// The policy does not inspect environment-specific risk beyond the fact that it
13+
/// is the default branch in the composed deployment gate. It moves the release to
14+
/// a deploy-ready stage and contributes both a side effect and metadata so the
15+
/// composition result stays auditable.
16+
/// </remarks>
17+
internal sealed class DefaultDeploymentPolicy : IMutationPolicy<ReleaseGateState>
18+
{
19+
/// <summary>
20+
/// Policy identifier used in composition metadata.
21+
/// </summary>
22+
public string Name => "DefaultDeployment";
23+
24+
/// <summary>
25+
/// Lowest priority in the deployment composition, so it acts as the fallback branch.
26+
/// </summary>
27+
public int Priority => 100;
28+
29+
/// <summary>
30+
/// Describes the fallback deployment behavior.
31+
/// </summary>
32+
public string Description => "Default non-production deployment path.";
33+
34+
/// <summary>
35+
/// Moves the release into the ready for deployment stage and emits an audit trace.
36+
/// </summary>
37+
/// <param name="mutation">The mutation being evaluated.</param>
38+
/// <param name="state">The current release state.</param>
39+
/// <returns>An allowed decision that marks the release ready for deployment.</returns>
40+
public PolicyDecision Evaluate(IMutation<ReleaseGateState> mutation, ReleaseGateState state)
41+
=> new()
42+
{
43+
IsAllowed = true,
44+
PolicyName = Name,
45+
Modifications = new Dictionary<string, object>
46+
{
47+
["State"] = state with { Stage = "ReadyForDeploy" },
48+
["SideEffect"] = SideEffect.Create("audit", "Default deployment path selected.")
49+
},
50+
Metadata = new Dictionary<string, object>
51+
{
52+
["deploymentPath"] = "default"
53+
}
54+
};
55+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using ModularityKit.Mutator.Abstractions.Engine;
2+
using ModularityKit.Mutator.Abstractions.Policies;
3+
using PolicyComposition.State;
4+
5+
namespace PolicyComposition.Policies.Deployment;
6+
7+
/// <summary>
8+
/// Stops production releases before fallback policies can be applied.
9+
/// </summary>
10+
/// <remarks>
11+
/// This policy exists to demonstrate the priority composition mode. It evaluates
12+
/// first, looks at the environment metadata, and returns a decisive denial when
13+
/// the release targets production. For any other environment, it allows the
14+
/// composition to continue to the next policy.
15+
/// </remarks>
16+
internal sealed class ProductionGuardPolicy : IMutationPolicy<ReleaseGateState>
17+
{
18+
/// <summary>
19+
/// Policy identifier used in decision metadata.
20+
/// </summary>
21+
public string Name => "ProductionGuard";
22+
23+
/// <summary>
24+
/// Highest priority in the deployment gate, so the production check runs first.
25+
/// </summary>
26+
public int Priority => 500;
27+
28+
/// <summary>
29+
/// Summarizes the production protection rule enforced by this policy.
30+
/// </summary>
31+
public string Description => "Blocks production releases before lower priority policies can run.";
32+
33+
/// <summary>
34+
/// Checks the environment metadata and either denies production or allows fallback.
35+
/// </summary>
36+
/// <param name="mutation">The mutation being evaluated.</param>
37+
/// <param name="state">The current release state.</param>
38+
/// <returns>
39+
/// A critical denial for production deployments, or an allowed decision that
40+
/// hands control to lower-priority policies.
41+
/// </returns>
42+
public PolicyDecision Evaluate(IMutation<ReleaseGateState> mutation, ReleaseGateState state)
43+
{
44+
var environment = GetString(mutation.Context.Metadata, "environment");
45+
46+
return string.Equals(environment, "production", StringComparison.OrdinalIgnoreCase)
47+
? PolicyDecision.DenyCritical("Production releases require a dedicated change window.", Name)
48+
: PolicyDecision.Allow(Name, $"Environment '{environment}' falls through to the next policy.");
49+
}
50+
51+
private static string GetString(IReadOnlyDictionary<string, object> metadata, string key)
52+
=> metadata.TryGetValue(key, out var value) && value is string text ? text : string.Empty;
53+
}

0 commit comments

Comments
 (0)