Skip to content

Commit ef1b875

Browse files
committed
Feat: Add composed mutation policy examples
1 parent 2d0b003 commit ef1b875

4 files changed

Lines changed: 246 additions & 0 deletions

File tree

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)