Skip to content
Merged
2 changes: 1 addition & 1 deletion Docs/Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ Why this matters:

### 2. Governance-Aware Policy Composition

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

Scope:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using FeatureFlags.State;
using ModularityKit.Mutator.Abstractions.Policies;

namespace FeatureFlags.Policies;

/// <summary>
/// Reusable policy compositions for sensitive feature flag changes.
/// </summary>
internal static class FeatureFlagGovernancePolicies
{
/// <summary>
/// Composed governance policy set for critical feature flag changes.
/// </summary>
public static IMutationPolicy<FeatureFlagsState> CriticalChanges() =>
PolicyComposition.AllOf(
name: "CriticalFeatureFlagGovernance",
policies:
[
new BusinessHoursPolicy(),
new RequireTwoManApprovalPolicy()
],
priority: 200,
description: "Requires business-hours execution and two-man approval for critical feature flag changes.");
}
5 changes: 2 additions & 3 deletions Examples/Core/FeatureFlags/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ private static async Task Main()
var provider = services.BuildServiceProvider();
var engine = provider.GetRequiredService<IMutationEngine>();

//engine.RegisterPolicy(new BusinessHoursPolicy());
engine.RegisterPolicy(new RequireTwoManApprovalPolicy());
engine.RegisterPolicy(FeatureFlagGovernancePolicies.CriticalChanges());

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

Expand All @@ -41,4 +40,4 @@ private static async Task Main()
Console.WriteLine($" Median execution time: {stats.MedianExecutionTime.TotalMilliseconds:F2} ms");
Console.WriteLine($" P95 execution time: {stats.P95ExecutionTime.TotalMilliseconds:F2} ms");
}
}
}
13 changes: 12 additions & 1 deletion Examples/Core/FeatureFlags/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The example covers three workflows:
- [`Mutations/DisableFeatureMutation.cs`](Mutations/DisableFeatureMutation.cs)
- [`Policies/BusinessHoursPolicy.cs`](Policies/BusinessHoursPolicy.cs)
- [`Policies/RequireTwoManApprovalPolicy.cs`](Policies/RequireTwoManApprovalPolicy.cs)
- [`Policies/FeatureFlagGovernancePolicies.cs`](Policies/FeatureFlagGovernancePolicies.cs)
- [`Scenarios/EnableNewCheckoutScenario.cs`](Scenarios/EnableNewCheckoutScenario.cs)
- [`Scenarios/DisableLegacyCheckoutScenario.cs`](Scenarios/DisableLegacyCheckoutScenario.cs)
- [`Scenarios/BatchFeatureToggleScenario.cs`](Scenarios/BatchFeatureToggleScenario.cs)
Expand All @@ -41,7 +42,7 @@ The example covers three workflows:

1. registers the engine with strict options
2. resolves `IMutationEngine`
3. registers `RequireTwoManApprovalPolicy`
3. registers the composed `CriticalFeatureFlagGovernance` policy set
4. runs the example scenarios
5. prints history for the main state
6. prints engine statistics
Expand Down Expand Up @@ -84,6 +85,16 @@ It demonstrates:

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

### Composed governance set

[`FeatureFlagGovernancePolicies.CriticalChanges`](Policies/FeatureFlagGovernancePolicies.cs) combines business-hours restrictions and two-man approval into one reusable policy set.

It demonstrates:

- `PolicyComposition.AllOf(...)`
- explicit policy merge behavior
- registering one composed policy instead of multiple hand-wired policy classes

## Scenarios

### Enable new checkout
Expand Down
48 changes: 48 additions & 0 deletions Examples/Core/PolicyComposition/Mutations/SubmitReleaseMutation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using ModularityKit.Mutator.Abstractions.Changes;
using ModularityKit.Mutator.Abstractions.Context;
using ModularityKit.Mutator.Abstractions.Engine;
using ModularityKit.Mutator.Abstractions.Results;
using PolicyComposition.State;

namespace PolicyComposition.Mutations;

/// <summary>
/// Submits a release and carries the governance metadata consumed by the policies.
/// </summary>
/// <remarks>
/// The mutation itself only moves the release into the submitted stage. The
/// interesting governance behavior lives in the composed policies, which read the
/// approval count, emergency flag, and target environment from the mutation
/// context metadata.
/// </remarks>
internal sealed class SubmitReleaseMutation(
string releaseName,
int approvals,
bool emergency,
string environment) : MutationBase<ReleaseGateState>(
CreateIntent(
operationName: "SubmitRelease",
category: "ReleaseGovernance",
description: "Submit a release through composed governance policies"),
MutationContext.User("release-manager", "Release Manager", "Release composition example")
with
{
StateId = releaseName,
Metadata = new Dictionary<string, object>
{
["approvals"] = approvals,
["emergency"] = emergency,
["environment"] = environment
}
})
{
/// <summary>
/// Marks the release as submitted before policy composition evaluates it.
/// </summary>
/// <param name="state">The current release state.</param>
/// <returns>A mutation result that moves the release into the submitted stage.</returns>
public override MutationResult<ReleaseGateState> Apply(ReleaseGateState state)
=> Success(
state with { Stage = "Submitted" },
StateChange.Modified("Stage", state.Stage, "Submitted"));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Engine;
using ModularityKit.Mutator.Abstractions.Policies;
using PolicyComposition.State;

namespace PolicyComposition.Policies.Approval;

/// <summary>
/// Appends audit trail information to an already allowed approval gate decision.
/// </summary>
/// <remarks>
/// This policy does not block or change the release state. It exists to show how
/// composed policies can contribute side effects and metadata independently of
/// the policy that made the main business decision.
/// </remarks>
internal sealed class AddAuditTrailPolicy : IMutationPolicy<ReleaseGateState>
{
/// <summary>
/// Stable policy identifier used in composition metadata and diagnostics.
/// </summary>
public string Name => "AddAuditTrail";

/// <summary>
/// Medium priority so this policy can be grouped with the approval gate it decorates.
/// </summary>
public int Priority => 200;

/// <summary>
/// Describes the audit trail side effect this policy adds to the composed result.
/// </summary>
public string Description => "Adds audit metadata and a notification side effect.";

/// <summary>
/// Emits one audit side effect and a simple metadata flag.
/// </summary>
/// <param name="mutation">The mutation being evaluated.</param>
/// <param name="state">The current release state.</param>
/// <returns>An allowed decision with audit metadata and a single side effect.</returns>
public PolicyDecision Evaluate(IMutation<ReleaseGateState> mutation, ReleaseGateState state)
=> new()
{
IsAllowed = true,
PolicyName = Name,
Modifications = new Dictionary<string, object>
{
["SideEffects"] = new[]
{
SideEffect.Create("audit", $"Release {state.ReleaseName} passed the composed approval gate.")
}
},
Metadata = new Dictionary<string, object>
{
["auditTrail"] = "enabled"
}
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Engine;
using ModularityKit.Mutator.Abstractions.Policies;
using PolicyComposition.State;

namespace PolicyComposition.Policies.Approval;

/// <summary>
/// Blocks release promotion until the expected approval threshold is present.
/// </summary>
/// <remarks>
/// The policy reads the approval count from mutation metadata, so the example can
/// show how governance input travels alongside the mutation itself.
/// When the threshold is met, the policy marks the release as approved and emits
/// an audit side effect. When it is not met, the policy returns a requirement and
/// error severity so the composed result can surface the missing approval.
/// </remarks>
internal sealed class RequireApprovalsPolicy : IMutationPolicy<ReleaseGateState>
{
/// <summary>
/// Stable policy identifier used in diagnostics and composition metadata.
/// </summary>
public string Name => "RequireApprovals";

/// <summary>
/// Higher than the audit only policy, so approval gating is evaluated first.
/// </summary>
public int Priority => 300;

/// <summary>
/// Explains the minimum-approval requirement that this policy enforces.
/// </summary>
public string Description => "Requires at least two approvals before the release can proceed.";

/// <summary>
/// Reads the approval count from mutation metadata and either allows or blocks the release.
/// </summary>
/// <param name="mutation">The mutation carrying government metadata.</param>
/// <param name="state">The current release state.</param>
/// <returns>
/// An allowed decision when approvals are enough, otherwise a blocking
/// decision with an approval requirement and error severity.
/// </returns>
public PolicyDecision Evaluate(IMutation<ReleaseGateState> mutation, ReleaseGateState state)
{
var approvals = GetInt32(mutation.Context.Metadata, "approvals");

return approvals >= 2
? new PolicyDecision
{
IsAllowed = true,
PolicyName = Name,
Modifications = new Dictionary<string, object>
{
["State"] = state with { Stage = "Approved" },
["SideEffect"] = SideEffect.Create("audit", $"Release approved with {approvals} approvals.")
},
Metadata = new Dictionary<string, object>
{
["approvalCount"] = approvals
}
}
: new PolicyDecision
{
IsAllowed = false,
PolicyName = Name,
Severity = PolicyDecisionSeverity.Error,
Reason = $"Release requires at least two approvals; found {approvals}.",
Requirements =
[
PolicyRequirement.Approval("release-manager", "Two approvals are required before promotion.")
],
Metadata = new Dictionary<string, object>
{
["approvalCount"] = approvals
}
};
}

private static int GetInt32(IReadOnlyDictionary<string, object> metadata, string key)
=> metadata.TryGetValue(key, out var value) && value is int number ? number : 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Engine;
using ModularityKit.Mutator.Abstractions.Policies;
using PolicyComposition.State;

namespace PolicyComposition.Policies.Deployment;

/// <summary>
/// Supplies the fallback deployment path for non production releases.
/// </summary>
/// <remarks>
/// The policy does not inspect environment-specific risk beyond the fact that it
/// is the default branch in the composed deployment gate. It moves the release to
/// a deploy-ready stage and contributes both a side effect and metadata so the
/// composition result stays auditable.
/// </remarks>
internal sealed class DefaultDeploymentPolicy : IMutationPolicy<ReleaseGateState>
{
/// <summary>
/// Policy identifier used in composition metadata.
/// </summary>
public string Name => "DefaultDeployment";

/// <summary>
/// Lowest priority in the deployment composition, so it acts as the fallback branch.
/// </summary>
public int Priority => 100;

/// <summary>
/// Describes the fallback deployment behavior.
/// </summary>
public string Description => "Default non-production deployment path.";

/// <summary>
/// Moves the release into the ready for deployment stage and emits an audit trace.
/// </summary>
/// <param name="mutation">The mutation being evaluated.</param>
/// <param name="state">The current release state.</param>
/// <returns>An allowed decision that marks the release ready for deployment.</returns>
public PolicyDecision Evaluate(IMutation<ReleaseGateState> mutation, ReleaseGateState state)
=> new()
{
IsAllowed = true,
PolicyName = Name,
Modifications = new Dictionary<string, object>
{
["State"] = state with { Stage = "ReadyForDeploy" },
["SideEffect"] = SideEffect.Create("audit", "Default deployment path selected.")
},
Metadata = new Dictionary<string, object>
{
["deploymentPath"] = "default"
}
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using ModularityKit.Mutator.Abstractions.Engine;
using ModularityKit.Mutator.Abstractions.Policies;
using PolicyComposition.State;

namespace PolicyComposition.Policies.Deployment;

/// <summary>
/// Stops production releases before fallback policies can be applied.
/// </summary>
/// <remarks>
/// This policy exists to demonstrate the priority composition mode. It evaluates
/// first, looks at the environment metadata, and returns a decisive denial when
/// the release targets production. For any other environment, it allows the
/// composition to continue to the next policy.
/// </remarks>
internal sealed class ProductionGuardPolicy : IMutationPolicy<ReleaseGateState>
{
/// <summary>
/// Policy identifier used in decision metadata.
/// </summary>
public string Name => "ProductionGuard";

/// <summary>
/// Highest priority in the deployment gate, so the production check runs first.
/// </summary>
public int Priority => 500;

/// <summary>
/// Summarizes the production protection rule enforced by this policy.
/// </summary>
public string Description => "Blocks production releases before lower priority policies can run.";

/// <summary>
/// Checks the environment metadata and either denies production or allows fallback.
/// </summary>
/// <param name="mutation">The mutation being evaluated.</param>
/// <param name="state">The current release state.</param>
/// <returns>
/// A critical denial for production deployments, or an allowed decision that
/// hands control to lower-priority policies.
/// </returns>
public PolicyDecision Evaluate(IMutation<ReleaseGateState> mutation, ReleaseGateState state)
{
var environment = GetString(mutation.Context.Metadata, "environment");

return string.Equals(environment, "production", StringComparison.OrdinalIgnoreCase)
? PolicyDecision.DenyCritical("Production releases require a dedicated change window.", Name)
: PolicyDecision.Allow(Name, $"Environment '{environment}' falls through to the next policy.");
}

private static string GetString(IReadOnlyDictionary<string, object> metadata, string key)
=> metadata.TryGetValue(key, out var value) && value is string text ? text : string.Empty;
}
Loading
Loading