From 50b5b180ba2b44cb1bf21188cf4879653a70a676 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 6 Jul 2026 00:21:11 +0200 Subject: [PATCH 01/10] Feat: PolicyCompositionMode enum and exception --- .../PolicyCompositionConflictException.cs | 32 +++++++++++++++++++ .../Policies/PolicyCompositionMode.cs | 24 ++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/Abstractions/Exceptions/PolicyCompositionConflictException.cs create mode 100644 src/Abstractions/Policies/PolicyCompositionMode.cs diff --git a/src/Abstractions/Exceptions/PolicyCompositionConflictException.cs b/src/Abstractions/Exceptions/PolicyCompositionConflictException.cs new file mode 100644 index 0000000..8c564be --- /dev/null +++ b/src/Abstractions/Exceptions/PolicyCompositionConflictException.cs @@ -0,0 +1,32 @@ +namespace ModularityKit.Mutator.Abstractions.Exceptions; + +/// +/// Exception thrown when composed policies attempt to produce incompatible outputs. +/// +/// +/// Initializes a new instance of the class. +/// +/// Name of the composed policy set. +/// Key or field that conflicted. +/// Policy names that contributed to the conflict. +public sealed class PolicyCompositionConflictException( + string compositionName, + string conflictKey, + IReadOnlyList policyNames) : MutationException( + $"Policy composition '{compositionName}' has a conflict for '{conflictKey}' between policies {string.Join(", ", policyNames)}.") +{ + /// + /// Name of the composed policy set that detected the conflict. + /// + public string CompositionName { get; } = compositionName; + + /// + /// Key or field that conflicted during composition. + /// + public string ConflictKey { get; } = conflictKey; + + /// + /// Policy names that contributed to the conflicting value. + /// + public IReadOnlyList PolicyNames { get; } = policyNames; +} diff --git a/src/Abstractions/Policies/PolicyCompositionMode.cs b/src/Abstractions/Policies/PolicyCompositionMode.cs new file mode 100644 index 0000000..efb979f --- /dev/null +++ b/src/Abstractions/Policies/PolicyCompositionMode.cs @@ -0,0 +1,24 @@ +namespace ModularityKit.Mutator.Abstractions.Policies; + +/// +/// Defines how composed policy set combines child policy decisions. +/// +public enum PolicyCompositionMode +{ + /// + /// All composed policies are evaluated and their outputs are merged. + /// Any blocking decision blocks the composition. + /// + AllOf = 0, + + /// + /// All composed policies are evaluated and at least one allowed branch must succeed. + /// Outputs from the allowed branches are merged. + /// + AnyOf = 1, + + /// + /// Policies are evaluated in priority order and the first decisive policy wins. + /// + Priority = 2 +} From 9cfb37daf66c6e2dd918332efd9b26b1afebaa0c Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 6 Jul 2026 00:23:27 +0200 Subject: [PATCH 02/10] Feat: policy decision merging and evaluation types --- .../Policies/PolicyDecisionMetadataMerger.cs | 101 ++++++++++++++++++ .../PolicyDecisionModificationMerger.cs | 101 ++++++++++++++++++ src/Abstractions/Policies/PolicyEvaluation.cs | 13 +++ 3 files changed, 215 insertions(+) create mode 100644 src/Abstractions/Policies/PolicyDecisionMetadataMerger.cs create mode 100644 src/Abstractions/Policies/PolicyDecisionModificationMerger.cs create mode 100644 src/Abstractions/Policies/PolicyEvaluation.cs diff --git a/src/Abstractions/Policies/PolicyDecisionMetadataMerger.cs b/src/Abstractions/Policies/PolicyDecisionMetadataMerger.cs new file mode 100644 index 0000000..3777273 --- /dev/null +++ b/src/Abstractions/Policies/PolicyDecisionMetadataMerger.cs @@ -0,0 +1,101 @@ +using ModularityKit.Mutator.Abstractions.Exceptions; + +namespace ModularityKit.Mutator.Abstractions.Policies; + +/// +/// Merges metadata for composed policy decisions. +/// +/// +/// The merger preserves composition audit metadata and folds child policy +/// metadata into the final decision. Metadata keys are merged deterministically. +/// When two child policies provide the same metadata key with different values, +/// the merge fails with policy composition conflict so ambiguous audit data is +/// never silently overwritten. +/// +internal static class PolicyDecisionMetadataMerger +{ + /// + /// The metadata key used to store the policy composition mode. + /// + private const string CompositionModeKey = "PolicyComposition.Mode"; + + /// + /// The metadata key used to store the ordered child policy evaluation summary. + /// + private const string CompositionPoliciesKey = "PolicyComposition.Policies"; + + /// + /// The metadata key used to store the policies that contributed to the composed result. + /// + private const string CompositionWinningPoliciesKey = "PolicyComposition.WinningPolicies"; + + /// + /// The metadata key used to store the policies that blocked the composed result. + /// + private const string CompositionBlockingPoliciesKey = "PolicyComposition.BlockingPolicies"; + + /// + /// Merges composition metadata with metadata produced by child policy decisions. + /// + /// The state type used by the evaluated policies. + /// The composed policy name. + /// The composition mode used to produce the final decision. + /// The ordered child policy evaluations. + /// The policies that contributed to the composed result. + /// The policies that blocked the composed result. + /// The merged metadata dictionary for the composed policy decision. + /// + /// Thrown when multiple child policies provide the same metadata key with different values. + /// + public static IReadOnlyDictionary Merge( + string compositionName, + PolicyCompositionMode mode, + IReadOnlyList> evaluations, + IReadOnlyList winningPolicyNames, + IReadOnlyList blockingPolicyNames) + { + var metadata = new Dictionary(StringComparer.Ordinal) + { + [CompositionModeKey] = mode.ToString(), + [CompositionPoliciesKey] = evaluations + .Select((evaluation, index) => new + { + index, + policy = evaluation.Policy.Name, + severity = evaluation.Decision.Severity.ToString(), + allowed = evaluation.Decision.IsAllowed + }) + .ToArray(), + [CompositionWinningPoliciesKey] = winningPolicyNames.ToArray(), + [CompositionBlockingPoliciesKey] = blockingPolicyNames.ToArray() + }; + + foreach (var evaluation in evaluations) + { + if (evaluation.Decision.Metadata is null) + continue; + + foreach (var (key, value) in evaluation.Decision.Metadata) + { + if (!metadata.TryGetValue(key, out var existing)) + { + metadata[key] = value; + continue; + } + + if (Equals(existing, value)) + continue; + + throw new PolicyCompositionConflictException( + compositionName, + $"metadata:{key}", + [.. evaluations + .Where(candidate => candidate.Decision.Metadata is not null && candidate.Decision.Metadata.ContainsKey(key)) + .Select(candidate => candidate.Policy.Name) + .Distinct(StringComparer.Ordinal)]); + } + } + + return metadata; + } +} diff --git a/src/Abstractions/Policies/PolicyDecisionModificationMerger.cs b/src/Abstractions/Policies/PolicyDecisionModificationMerger.cs new file mode 100644 index 0000000..4353c44 --- /dev/null +++ b/src/Abstractions/Policies/PolicyDecisionModificationMerger.cs @@ -0,0 +1,101 @@ +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Exceptions; + +namespace ModularityKit.Mutator.Abstractions.Policies; + +/// +/// Merges modification payloads for composed policy decisions. +/// +/// +/// The merger folds child policy modification payloads into single composed +/// modification dictionary. Regular modification keys must either be unique or +/// contain equal values across contributing policies. Side effects are treated +/// specially and are collected into a single side effects collection. +/// +internal static class PolicyDecisionModificationMerger +{ + /// + /// The modification key used for single side effect. + /// + private const string SideEffectKey = "SideEffect"; + + /// + /// The modification key used for collection of side effects. + /// + private const string SideEffectsKey = "SideEffects"; + + /// + /// Merges modification payloads produced by child policy decisions. + /// + /// The state type used by the evaluated policies. + /// The composed policy name. + /// The child policy evaluations that contribute modifications. + /// + /// The merged modification dictionary, or null when no modifications were produced. + /// + /// + /// Thrown when multiple policies provide the same modification key with different values, + /// or when the side effects payload has an unsupported shape. + /// + public static IReadOnlyDictionary? Merge( + string compositionName, + IReadOnlyList> evaluations) + { + var merged = new Dictionary(StringComparer.Ordinal); + var sources = new Dictionary(StringComparer.Ordinal); + var sideEffects = new List(); + + foreach (var evaluation in evaluations) + { + if (evaluation.Decision.Modifications is null) + continue; + + foreach (var (key, value) in evaluation.Decision.Modifications) + { + if (key == SideEffectKey) + { + if (value is SideEffect sideEffect) + { + sideEffects.Add(sideEffect); + } + + continue; + } + + if (key == SideEffectsKey) + { + if (value is IEnumerable effects) + { + sideEffects.AddRange(effects); + continue; + } + + throw new PolicyCompositionConflictException( + compositionName, + SideEffectsKey, + [evaluation.Policy.Name]); + } + + if (!merged.TryGetValue(key, out var existing)) + { + merged[key] = value; + sources[key] = evaluation.Policy.Name; + continue; + } + + if (Equals(existing, value)) + continue; + + throw new PolicyCompositionConflictException( + compositionName, + key, + [sources[key], evaluation.Policy.Name]); + } + } + + if (sideEffects.Count > 0) + merged[SideEffectsKey] = sideEffects; + + return merged.Count == 0 ? null : merged; + } +} diff --git a/src/Abstractions/Policies/PolicyEvaluation.cs b/src/Abstractions/Policies/PolicyEvaluation.cs new file mode 100644 index 0000000..b63288f --- /dev/null +++ b/src/Abstractions/Policies/PolicyEvaluation.cs @@ -0,0 +1,13 @@ +using ModularityKit.Mutator.Abstractions.Engine; + +namespace ModularityKit.Mutator.Abstractions.Policies; + +/// +/// Captures one evaluated policy and its decision. +/// +/// The state type used by the evaluated policy. +/// The policy that was evaluated. +/// The decision produced by the policy. +internal readonly record struct PolicyEvaluation( + IMutationPolicy Policy, + PolicyDecision Decision); From b3a05a2d1bd74baeeb9cb3b7bf9a8fc44d9d92c3 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 6 Jul 2026 00:25:03 +0200 Subject: [PATCH 03/10] Feat: policy composition and decision composer --- .../Policies/ComposedMutationPolicy.cs | 97 +++++ .../Policies/PolicyComposition.cs | 71 ++++ .../Policies/PolicyDecisionComposer.cs | 331 ++++++++++++++++++ 3 files changed, 499 insertions(+) create mode 100644 src/Abstractions/Policies/ComposedMutationPolicy.cs create mode 100644 src/Abstractions/Policies/PolicyComposition.cs create mode 100644 src/Abstractions/Policies/PolicyDecisionComposer.cs diff --git a/src/Abstractions/Policies/ComposedMutationPolicy.cs b/src/Abstractions/Policies/ComposedMutationPolicy.cs new file mode 100644 index 0000000..5a07530 --- /dev/null +++ b/src/Abstractions/Policies/ComposedMutationPolicy.cs @@ -0,0 +1,97 @@ +using ModularityKit.Mutator.Abstractions.Engine; + +namespace ModularityKit.Mutator.Abstractions.Policies; + +/// +/// Represents one composed policy built from multiple child policies. +/// +/// +/// The type is intentionally internal because callers are expected to use the +/// factory methods on instead of constructing +/// composed policies directly. That keeps the composition surface explicit and +/// allows the implementation to enforce validation and ordering rules in one +/// place. +/// +internal sealed class ComposedMutationPolicy : IMutationPolicy +{ + private readonly IReadOnlyList> _policies; + + /// + /// Creates a composed policy from a validated set of child policies. + /// + /// The composed policy name. + /// The priority of the composed policy. + /// An optional human-readable description. + /// The composition mode that controls decision merging. + /// The child policies to evaluate. + public ComposedMutationPolicy( + string name, + int priority, + string? description, + PolicyCompositionMode mode, + IEnumerable> policies) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("A composed policy name is required.", nameof(name)); + + ArgumentNullException.ThrowIfNull(policies); + + Name = name; + Priority = priority; + Description = description; + Mode = mode; + _policies = ValidatePolicies(policies); + } + + public string Name { get; } + + public int Priority { get; } + + public string? Description { get; } + + private PolicyCompositionMode Mode { get; } + + public PolicyDecision Evaluate(IMutation mutation, TState state) + => EvaluateAsync(mutation, state, CancellationToken.None).GetAwaiter().GetResult(); + + public Task EvaluateAsync( + IMutation mutation, + TState state, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(mutation); + + return PolicyDecisionComposer.ComposeAsync( + Name, + Mode, + _policies, + mutation, + state, + cancellationToken); + } + + /// + /// Validates that the composed policy contains at least one non-null child policy. + /// + /// The candidate child policies. + /// The validated list of child policies. + private static IReadOnlyList> ValidatePolicies(IEnumerable> policies) + { + var validated = new List>(); + var index = 0; + + foreach (var policy in policies) + { + if (policy is null) + throw new ArgumentException($"Child policy at index {index} is null.", nameof(policies)); + + validated.Add(policy); + index++; + } + + if (validated.Count == 0) + throw new ArgumentException("At least one child policy is required.", nameof(policies)); + + return validated; + } +} diff --git a/src/Abstractions/Policies/PolicyComposition.cs b/src/Abstractions/Policies/PolicyComposition.cs new file mode 100644 index 0000000..61c1b45 --- /dev/null +++ b/src/Abstractions/Policies/PolicyComposition.cs @@ -0,0 +1,71 @@ +namespace ModularityKit.Mutator.Abstractions.Policies; + +/// +/// Factory methods for composing multiple mutation policies into one deterministic policy. +/// +public static class PolicyComposition +{ + /// + /// Creates composed policy that requires every child policy to allow the mutation. + /// + /// The state type handled by the composed policy. + /// The composed policy name. + /// The child policies to evaluate. + /// The priority of the composed policy. + /// An optional human readable description. + /// A composed policy that merges all child decisions. + public static IMutationPolicy AllOf( + string name, + IEnumerable> policies, + int priority = 0, + string? description = null) + => new ComposedMutationPolicy( + name, + priority, + description, + PolicyCompositionMode.AllOf, + policies); + + /// + /// Creates composed policy that succeeds when at least one child policy allows the mutation. + /// + /// The state type handled by the composed policy. + /// The composed policy name. + /// The child policies to evaluate. + /// The priority of the composed policy. + /// An optional human readable description. + /// A composed policy that selects the allowed branch when one exists. + public static IMutationPolicy AnyOf( + string name, + IEnumerable> policies, + int priority = 0, + string? description = null) + => new ComposedMutationPolicy( + name, + priority, + description, + PolicyCompositionMode.AnyOf, + policies); + + /// + /// Creates a composed policy that evaluates children in deterministic priority order and + /// returns the first decisive result. + /// + /// The state type handled by the composed policy. + /// The composed policy name. + /// The child policies to evaluate. + /// The priority of the composed policy. + /// An optional human readable description. + /// A composed policy that stops at the first decisive child decision. + public static IMutationPolicy Priority( + string name, + IEnumerable> policies, + int priority = 0, + string? description = null) + => new ComposedMutationPolicy( + name, + priority, + description, + PolicyCompositionMode.Priority, + policies); +} diff --git a/src/Abstractions/Policies/PolicyDecisionComposer.cs b/src/Abstractions/Policies/PolicyDecisionComposer.cs new file mode 100644 index 0000000..6e1e416 --- /dev/null +++ b/src/Abstractions/Policies/PolicyDecisionComposer.cs @@ -0,0 +1,331 @@ +using ModularityKit.Mutator.Abstractions.Engine; + +namespace ModularityKit.Mutator.Abstractions.Policies; + +/// +/// Merges policy decisions using explicit composition rules. +/// +/// +/// The composer is the implementation behind . +/// It evaluates child policies in deterministic order, applies the composition +/// mode semantics, and delegates metadata and modification merging to dedicated +/// helpers so the resulting decision stays auditable. +/// +internal static class PolicyDecisionComposer +{ + /// + /// Evaluates policy set and returns composed decision. + /// + /// The state type used by the policies. + /// The composed policy name. + /// The composition mode. + /// The child policies to evaluate. + /// The mutation being evaluated. + /// The current state. + /// The cancellation token. + /// A composed policy decision. + public static Task ComposeAsync( + string compositionName, + PolicyCompositionMode mode, + IReadOnlyList> policies, + IMutation mutation, + TState state, + CancellationToken cancellationToken) + { + var orderedPolicies = policies + .OrderByDescending(policy => policy.Priority) + .ThenBy(policy => policy.Name, StringComparer.Ordinal) + .ThenBy(policy => policy.GetType().FullName ?? string.Empty, StringComparer.Ordinal) + .ToArray(); + + return mode switch + { + PolicyCompositionMode.AllOf => ComposeFullDecisionAsync( + compositionName, + orderedPolicies, + mutation, + state, + cancellationToken, + ComposeAllOfDecision), + PolicyCompositionMode.AnyOf => ComposeFullDecisionAsync( + compositionName, + orderedPolicies, + mutation, + state, + cancellationToken, + ComposeAnyOfDecision), + PolicyCompositionMode.Priority => ComposePriorityDecisionAsync( + compositionName, + orderedPolicies, + mutation, + state, + cancellationToken), + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported policy composition mode.") + }; + } + + /// + /// Returns whether decision is decisive in priority mode. + /// + /// The decision to inspect. + /// true when the decision should stop priority evaluation. + private static bool IsDecisive(PolicyDecision decision) + => !decision.IsAllowed || decision.Modifications is not null || decision.Requirements is not null; + + /// + /// Evaluates all child policies and composes their results using selected strategy. + /// + /// The state type used by the policies. + /// The composed policy name. + /// The policies in deterministic evaluation order. + /// The mutation being evaluated. + /// The current state. + /// The cancellation token. + /// The composition strategy to apply after evaluation. + /// A composed policy decision. + private static async Task ComposeFullDecisionAsync( + string compositionName, + IReadOnlyList> orderedPolicies, + IMutation mutation, + TState state, + CancellationToken cancellationToken, + Func>, PolicyDecision> compose) + { + var evaluations = await EvaluatePoliciesAsync(orderedPolicies, mutation, state, cancellationToken) + .ConfigureAwait(false); + + return compose(compositionName, evaluations); + } + + /// + /// Evaluates policies in priority order and stops at the first decisive result. + /// + /// The state type used by the policies. + /// The composed policy name. + /// The policies in deterministic evaluation order. + /// The mutation being evaluated. + /// The current state. + /// The cancellation token. + /// A composed policy decision. + private static async Task ComposePriorityDecisionAsync( + string compositionName, + IReadOnlyList> orderedPolicies, + IMutation mutation, + TState state, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(compositionName); + ArgumentNullException.ThrowIfNull(orderedPolicies); + ArgumentNullException.ThrowIfNull(mutation); + var evaluations = new List>(orderedPolicies.Count); + + foreach (var policy in orderedPolicies) + { + var decision = await policy.EvaluateAsync(mutation, state, cancellationToken).ConfigureAwait(false); + var evaluation = new PolicyEvaluation(policy, decision); + evaluations.Add(evaluation); + + if (IsDecisive(decision)) + { + return ComposeDecision( + compositionName, + PolicyCompositionMode.Priority, + evaluations, + [evaluation], + allow: decision.IsAllowed, + winningPolicyNames: [policy.Name], + blockingPolicyNames: decision.IsAllowed ? [] : [policy.Name]); + } + } + + return ComposeDecision( + compositionName, + PolicyCompositionMode.Priority, + evaluations, + evaluations, + allow: true, + winningPolicyNames: evaluations.Select(evaluation => evaluation.Policy.Name), + blockingPolicyNames: []); + } + + /// + /// Evaluates all policies without short-circuiting. + /// + /// The state type used by the policies. + /// The policies in deterministic evaluation order. + /// The mutation being evaluated. + /// The current state. + /// The cancellation token. + /// The ordered policy evaluations. + private static async Task>> EvaluatePoliciesAsync( + IReadOnlyList> orderedPolicies, + IMutation mutation, + TState state, + CancellationToken cancellationToken) + { + var evaluations = new List>(orderedPolicies.Count); + + foreach (var policy in orderedPolicies) + { + var decision = await policy.EvaluateAsync(mutation, state, cancellationToken).ConfigureAwait(false); + evaluations.Add(new PolicyEvaluation(policy, decision)); + } + + return evaluations; + } + + /// + /// Composes the final decision for an AllOf policy set. + /// + /// The state type used by the policies. + /// The composed policy name. + /// All evaluations performed for the composition. + /// A composed policy decision. + private static PolicyDecision ComposeAllOfDecision( + string compositionName, + IReadOnlyList> evaluations) + { + var blockedPolicies = evaluations + .Where(evaluation => !evaluation.Decision.IsAllowed) + .Select(evaluation => evaluation.Policy.Name) + .ToArray(); + + var allow = blockedPolicies.Length == 0; + + return ComposeDecision( + compositionName, + PolicyCompositionMode.AllOf, + evaluations, + evaluations, + allow, + winningPolicyNames: allow + ? evaluations.Select(evaluation => evaluation.Policy.Name) + : blockedPolicies, + blockingPolicyNames: blockedPolicies); + } + + /// + /// Composes the final decision for an AnyOf policy set. + /// + /// The state type used by the policies. + /// The composed policy name. + /// All evaluations performed for the composition. + /// A composed policy decision. + private static PolicyDecision ComposeAnyOfDecision( + string compositionName, + IReadOnlyList> evaluations) + { + var allowedEvaluations = evaluations.Where(evaluation => evaluation.Decision.IsAllowed).ToArray(); + var selectedEvaluations = allowedEvaluations.Length > 0 ? allowedEvaluations : evaluations; + var allow = allowedEvaluations.Length > 0; + + return ComposeDecision( + compositionName, + PolicyCompositionMode.AnyOf, + evaluations, + selectedEvaluations, + allow, + winningPolicyNames: selectedEvaluations.Select(evaluation => evaluation.Policy.Name), + blockingPolicyNames: allow + ? evaluations.Where(evaluation => !evaluation.Decision.IsAllowed).Select(evaluation => evaluation.Policy.Name) + : evaluations.Select(evaluation => evaluation.Policy.Name)); + } + + /// + /// Builds the final composed decision payload. + /// + /// The state type used by the policies. + /// The composed policy name. + /// The composition mode. + /// All evaluations performed for the composition. + /// The evaluations that contribute to the final result. + /// Whether the composition is allowed. + /// The policies that contributed to the result. + /// The policies that blocked the result. + /// A composed policy decision. + private static PolicyDecision ComposeDecision( + string compositionName, + PolicyCompositionMode mode, + IReadOnlyList> allEvaluations, + IReadOnlyList> selectedEvaluations, + bool allow, + IEnumerable winningPolicyNames, + IEnumerable blockingPolicyNames) + { + var winningNames = winningPolicyNames.ToArray(); + var blockingNames = blockingPolicyNames.ToArray(); + var selectedDecisions = selectedEvaluations.Select(evaluation => evaluation.Decision).ToArray(); + var severity = selectedDecisions.Max(decision => decision.Severity); + var metadata = PolicyDecisionMetadataMerger.Merge( + compositionName, + mode, + allEvaluations, + winningNames, + blockingNames); + var requirements = selectedDecisions + .SelectMany(decision => decision.Requirements ?? []) + .ToArray(); + var modifications = PolicyDecisionModificationMerger.Merge(compositionName, selectedEvaluations); + var reason = BuildReason(compositionName, allow, winningNames, blockingNames, selectedDecisions); + + return new PolicyDecision + { + IsAllowed = allow, + PolicyName = compositionName, + Reason = reason, + Severity = severity, + Requirements = requirements, + Modifications = modifications, + Metadata = metadata, + Timestamp = DateTimeOffset.UtcNow + }; + } + + /// + /// Builds reason for composed decision. + /// + /// The composed policy name. + /// Whether the composition is allowed. + /// The policies that contributed to the result. + /// The policies that blocked the result. + /// The selected decisions used to derive the reason. + /// The formatted reason string. + private static string BuildReason(string compositionName, bool allow, + IReadOnlyList winningNames, + IReadOnlyList blockingNames, + IReadOnlyList decisions) => allow + ? BuildAllowReason(compositionName, winningNames) + : BuildBlockReason(compositionName, blockingNames, decisions); + + /// + /// Builds the allow reason for composed decision. + /// + /// The composed policy name. + /// The policies that contributed to the result. + /// The formatted allow reason. + private static string BuildAllowReason(string compositionName, IReadOnlyList winningNames) + => $"Policy composition '{compositionName}' allowed by {string.Join(", ", winningNames)}."; + + /// + /// Builds the block reason for composed decision. + /// + /// The composed policy name. + /// The policies that blocked the result. + /// The selected decisions used to derive the reason. + /// The formatted block reason. + private static string BuildBlockReason( + string compositionName, + IReadOnlyList blockingNames, + IReadOnlyList decisions) + { + var reasons = decisions + .Select(decision => decision.Reason) + .Where(reason => !string.IsNullOrWhiteSpace(reason)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + return reasons.Length == 0 + ? $"Policy composition '{compositionName}' blocked by {string.Join(", ", blockingNames)}." + : $"Policy composition '{compositionName}' blocked by {string.Join(", ", blockingNames)}: {string.Join(" | ", reasons)}."; + } +} From 0caba57f917709ceb0d074c7c5e0243f8688cef7 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 6 Jul 2026 00:25:48 +0200 Subject: [PATCH 04/10] Feat: composed governance policy for feature flags --- .../Policies/FeatureFlagGovernancePolicies.cs | 24 +++++++++++++++++++ Examples/Core/FeatureFlags/Program.cs | 5 ++-- Examples/Core/FeatureFlags/README.md | 13 +++++++++- 3 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 Examples/Core/FeatureFlags/Policies/FeatureFlagGovernancePolicies.cs diff --git a/Examples/Core/FeatureFlags/Policies/FeatureFlagGovernancePolicies.cs b/Examples/Core/FeatureFlags/Policies/FeatureFlagGovernancePolicies.cs new file mode 100644 index 0000000..10ad98d --- /dev/null +++ b/Examples/Core/FeatureFlags/Policies/FeatureFlagGovernancePolicies.cs @@ -0,0 +1,24 @@ +using FeatureFlags.State; +using ModularityKit.Mutator.Abstractions.Policies; + +namespace FeatureFlags.Policies; + +/// +/// Reusable policy compositions for sensitive feature flag changes. +/// +internal static class FeatureFlagGovernancePolicies +{ + /// + /// Composed governance policy set for critical feature flag changes. + /// + public static IMutationPolicy 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."); +} diff --git a/Examples/Core/FeatureFlags/Program.cs b/Examples/Core/FeatureFlags/Program.cs index d8fe1c8..808c8d4 100644 --- a/Examples/Core/FeatureFlags/Program.cs +++ b/Examples/Core/FeatureFlags/Program.cs @@ -17,8 +17,7 @@ private static async Task Main() var provider = services.BuildServiceProvider(); var engine = provider.GetRequiredService(); - //engine.RegisterPolicy(new BusinessHoursPolicy()); - engine.RegisterPolicy(new RequireTwoManApprovalPolicy()); + engine.RegisterPolicy(FeatureFlagGovernancePolicies.CriticalChanges()); Console.WriteLine("=== ModularityKit.Mutators - Complete Example ===\n"); @@ -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"); } -} \ No newline at end of file +} diff --git a/Examples/Core/FeatureFlags/README.md b/Examples/Core/FeatureFlags/README.md index dd682dc..7b4b06f 100644 --- a/Examples/Core/FeatureFlags/README.md +++ b/Examples/Core/FeatureFlags/README.md @@ -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) @@ -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 @@ -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 From 2d0b0036c7a7535c5b3bee2099cc715d72cf5e74 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 6 Jul 2026 00:27:48 +0200 Subject: [PATCH 05/10] Feat: Enhance policy evaluation ordering --- .../Evaluation/MutationPolicyEvaluator.cs | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Runtime/Internal/Evaluation/MutationPolicyEvaluator.cs b/src/Runtime/Internal/Evaluation/MutationPolicyEvaluator.cs index fa3f85b..925c145 100644 --- a/src/Runtime/Internal/Evaluation/MutationPolicyEvaluator.cs +++ b/src/Runtime/Internal/Evaluation/MutationPolicyEvaluator.cs @@ -8,6 +8,11 @@ namespace ModularityKit.Mutator.Runtime.Internal.Evaluation; /// /// Evaluates registered mutation policies in runtime priority order. /// +/// +/// The evaluator resolves policies from the registry, applies deterministic +/// priority ordering, invokes each policy with optional timeout handling, and +/// returns the first blocking or modifying decision. +/// internal sealed class MutationPolicyEvaluator( IPolicyRegistry policyRegistry, MutationEngineOptions options) @@ -32,7 +37,10 @@ public async Task EvaluateAsync( { var policies = _policyRegistry.GetPolicies(); - foreach (var policy in policies.OrderByDescending(p => p.Priority)) + foreach (var policy in policies + .OrderByDescending(p => p.Priority) + .ThenBy(p => p.Name, StringComparer.Ordinal) + .ThenBy(p => p.GetType().FullName ?? string.Empty, StringComparer.Ordinal)) { var decision = await EvaluatePolicyAsync( policy, @@ -47,6 +55,21 @@ public async Task EvaluateAsync( return PolicyDecision.Allow(); } + /// + /// Evaluates a single policy with optional runtime timeout handling. + /// + /// The state type handled by the policy. + /// The policy to evaluate. + /// The mutation being evaluated. + /// The current state snapshot. + /// Token used to cancel policy evaluation. + /// The decision produced by the policy. + /// + /// Thrown when policy evaluation exceeds the configured timeout. + /// + /// + /// Thrown when the policy evaluation fails with a non-cancellation exception. + /// private async Task EvaluatePolicyAsync( IMutationPolicy policy, IMutation mutation, @@ -82,6 +105,18 @@ private async Task EvaluatePolicyAsync( } } + /// + /// Invokes a policy and normalizes unexpected failures into policy evaluation exceptions. + /// + /// The state type handled by the policy. + /// The policy to invoke. + /// The mutation being evaluated. + /// The current state snapshot. + /// Token used to cancel policy evaluation. + /// The decision produced by the policy. + /// + /// Thrown when the policy evaluation fails with a non-cancellation exception. + /// private static async Task InvokePolicyAsync( IMutationPolicy policy, IMutation mutation, From ef1b875556d0e90d73bb4070b2b7ba4105f775f4 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 6 Jul 2026 00:33:28 +0200 Subject: [PATCH 06/10] Feat: Add composed mutation policy examples --- .../Policies/Approval/AddAuditTrailPolicy.cs | 56 +++++++++++++ .../Approval/RequireApprovalsPolicy.cs | 82 +++++++++++++++++++ .../Deployment/DefaultDeploymentPolicy.cs | 55 +++++++++++++ .../Deployment/ProductionGuardPolicy.cs | 53 ++++++++++++ 4 files changed, 246 insertions(+) create mode 100644 Examples/Core/PolicyComposition/Policies/Approval/AddAuditTrailPolicy.cs create mode 100644 Examples/Core/PolicyComposition/Policies/Approval/RequireApprovalsPolicy.cs create mode 100644 Examples/Core/PolicyComposition/Policies/Deployment/DefaultDeploymentPolicy.cs create mode 100644 Examples/Core/PolicyComposition/Policies/Deployment/ProductionGuardPolicy.cs diff --git a/Examples/Core/PolicyComposition/Policies/Approval/AddAuditTrailPolicy.cs b/Examples/Core/PolicyComposition/Policies/Approval/AddAuditTrailPolicy.cs new file mode 100644 index 0000000..5558abb --- /dev/null +++ b/Examples/Core/PolicyComposition/Policies/Approval/AddAuditTrailPolicy.cs @@ -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; + +/// +/// Appends audit trail information to an already allowed approval gate decision. +/// +/// +/// 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. +/// +internal sealed class AddAuditTrailPolicy : IMutationPolicy +{ + /// + /// Stable policy identifier used in composition metadata and diagnostics. + /// + public string Name => "AddAuditTrail"; + + /// + /// Medium priority so this policy can be grouped with the approval gate it decorates. + /// + public int Priority => 200; + + /// + /// Describes the audit trail side effect this policy adds to the composed result. + /// + public string Description => "Adds audit metadata and a notification side effect."; + + /// + /// Emits one audit side effect and a simple metadata flag. + /// + /// The mutation being evaluated. + /// The current release state. + /// An allowed decision with audit metadata and a single side effect. + public PolicyDecision Evaluate(IMutation mutation, ReleaseGateState state) + => new() + { + IsAllowed = true, + PolicyName = Name, + Modifications = new Dictionary + { + ["SideEffects"] = new[] + { + SideEffect.Create("audit", $"Release {state.ReleaseName} passed the composed approval gate.") + } + }, + Metadata = new Dictionary + { + ["auditTrail"] = "enabled" + } + }; +} diff --git a/Examples/Core/PolicyComposition/Policies/Approval/RequireApprovalsPolicy.cs b/Examples/Core/PolicyComposition/Policies/Approval/RequireApprovalsPolicy.cs new file mode 100644 index 0000000..ba8b5e1 --- /dev/null +++ b/Examples/Core/PolicyComposition/Policies/Approval/RequireApprovalsPolicy.cs @@ -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; + +/// +/// Blocks release promotion until the expected approval threshold is present. +/// +/// +/// 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. +/// +internal sealed class RequireApprovalsPolicy : IMutationPolicy +{ + /// + /// Stable policy identifier used in diagnostics and composition metadata. + /// + public string Name => "RequireApprovals"; + + /// + /// Higher than the audit only policy, so approval gating is evaluated first. + /// + public int Priority => 300; + + /// + /// Explains the minimum-approval requirement that this policy enforces. + /// + public string Description => "Requires at least two approvals before the release can proceed."; + + /// + /// Reads the approval count from mutation metadata and either allows or blocks the release. + /// + /// The mutation carrying government metadata. + /// The current release state. + /// + /// An allowed decision when approvals are enough, otherwise a blocking + /// decision with an approval requirement and error severity. + /// + public PolicyDecision Evaluate(IMutation mutation, ReleaseGateState state) + { + var approvals = GetInt32(mutation.Context.Metadata, "approvals"); + + return approvals >= 2 + ? new PolicyDecision + { + IsAllowed = true, + PolicyName = Name, + Modifications = new Dictionary + { + ["State"] = state with { Stage = "Approved" }, + ["SideEffect"] = SideEffect.Create("audit", $"Release approved with {approvals} approvals.") + }, + Metadata = new Dictionary + { + ["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 + { + ["approvalCount"] = approvals + } + }; + } + + private static int GetInt32(IReadOnlyDictionary metadata, string key) + => metadata.TryGetValue(key, out var value) && value is int number ? number : 0; +} diff --git a/Examples/Core/PolicyComposition/Policies/Deployment/DefaultDeploymentPolicy.cs b/Examples/Core/PolicyComposition/Policies/Deployment/DefaultDeploymentPolicy.cs new file mode 100644 index 0000000..93ba654 --- /dev/null +++ b/Examples/Core/PolicyComposition/Policies/Deployment/DefaultDeploymentPolicy.cs @@ -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; + +/// +/// Supplies the fallback deployment path for non production releases. +/// +/// +/// 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. +/// +internal sealed class DefaultDeploymentPolicy : IMutationPolicy +{ + /// + /// Policy identifier used in composition metadata. + /// + public string Name => "DefaultDeployment"; + + /// + /// Lowest priority in the deployment composition, so it acts as the fallback branch. + /// + public int Priority => 100; + + /// + /// Describes the fallback deployment behavior. + /// + public string Description => "Default non-production deployment path."; + + /// + /// Moves the release into the ready for deployment stage and emits an audit trace. + /// + /// The mutation being evaluated. + /// The current release state. + /// An allowed decision that marks the release ready for deployment. + public PolicyDecision Evaluate(IMutation mutation, ReleaseGateState state) + => new() + { + IsAllowed = true, + PolicyName = Name, + Modifications = new Dictionary + { + ["State"] = state with { Stage = "ReadyForDeploy" }, + ["SideEffect"] = SideEffect.Create("audit", "Default deployment path selected.") + }, + Metadata = new Dictionary + { + ["deploymentPath"] = "default" + } + }; +} diff --git a/Examples/Core/PolicyComposition/Policies/Deployment/ProductionGuardPolicy.cs b/Examples/Core/PolicyComposition/Policies/Deployment/ProductionGuardPolicy.cs new file mode 100644 index 0000000..d530610 --- /dev/null +++ b/Examples/Core/PolicyComposition/Policies/Deployment/ProductionGuardPolicy.cs @@ -0,0 +1,53 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using PolicyComposition.State; + +namespace PolicyComposition.Policies.Deployment; + +/// +/// Stops production releases before fallback policies can be applied. +/// +/// +/// 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. +/// +internal sealed class ProductionGuardPolicy : IMutationPolicy +{ + /// + /// Policy identifier used in decision metadata. + /// + public string Name => "ProductionGuard"; + + /// + /// Highest priority in the deployment gate, so the production check runs first. + /// + public int Priority => 500; + + /// + /// Summarizes the production protection rule enforced by this policy. + /// + public string Description => "Blocks production releases before lower priority policies can run."; + + /// + /// Checks the environment metadata and either denies production or allows fallback. + /// + /// The mutation being evaluated. + /// The current release state. + /// + /// A critical denial for production deployments, or an allowed decision that + /// hands control to lower-priority policies. + /// + public PolicyDecision Evaluate(IMutation 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 metadata, string key) + => metadata.TryGetValue(key, out var value) && value is string text ? text : string.Empty; +} \ No newline at end of file From c67063dc8f2d33f644cbee974b9d1b9ee7cb1723 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 6 Jul 2026 00:35:54 +0200 Subject: [PATCH 07/10] Feat: Extend PolicyComposition example with governance policies --- .../Emergency/ApprovalFallbackPolicy.cs | 63 ++++++++++ .../Emergency/EmergencyOverridePolicy.cs | 67 +++++++++++ .../Policies/ReleaseGovernancePolicies.cs | 109 ++++++++++++++++++ .../Policies/Shared/SetOwnerPolicy.cs | 48 ++++++++ 4 files changed, 287 insertions(+) create mode 100644 Examples/Core/PolicyComposition/Policies/Emergency/ApprovalFallbackPolicy.cs create mode 100644 Examples/Core/PolicyComposition/Policies/Emergency/EmergencyOverridePolicy.cs create mode 100644 Examples/Core/PolicyComposition/Policies/ReleaseGovernancePolicies.cs create mode 100644 Examples/Core/PolicyComposition/Policies/Shared/SetOwnerPolicy.cs diff --git a/Examples/Core/PolicyComposition/Policies/Emergency/ApprovalFallbackPolicy.cs b/Examples/Core/PolicyComposition/Policies/Emergency/ApprovalFallbackPolicy.cs new file mode 100644 index 0000000..b70d7b5 --- /dev/null +++ b/Examples/Core/PolicyComposition/Policies/Emergency/ApprovalFallbackPolicy.cs @@ -0,0 +1,63 @@ +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using PolicyComposition.State; + +namespace PolicyComposition.Policies.Emergency; + +/// +/// Supplies the approval-based fallback branch for the emergency gate. +/// +/// +/// This policy is only intended to make the emergency composition reusable when +/// the explicit override is unavailable. It checks the same approval count used by +/// the standard approval policy, but it maps the successful outcome to a different +/// release stage so the example can show branch selection in the merged decision. +/// +internal sealed class ApprovalFallbackPolicy : IMutationPolicy +{ + /// + /// Policy identifier used in composed diagnostics. + /// + public string Name => "ApprovalFallback"; + + /// + /// Lower than the override branch but high enough to participate in emergency gating. + /// + public int Priority => 200; + + /// + /// Describes the approval-based fallback path. + /// + public string Description => "Fallback branch that allows the release once approvals exist."; + + /// + /// Uses the approval count to decide whether the emergency fallback can proceed. + /// + /// The mutation being evaluated. + /// The current release state. + /// + /// An allowed decision that advances the release through the fallback stage + /// or a blocking decision when approvals are missing. + /// + public PolicyDecision Evaluate(IMutation mutation, ReleaseGateState state) + { + var approvals = GetInt32(mutation.Context.Metadata, "approvals"); + + return approvals >= 2 + ? new PolicyDecision + { + IsAllowed = true, + PolicyName = Name, + Modifications = new Dictionary + { + ["State"] = state with { Stage = "ApprovedViaFallback" }, + ["SideEffect"] = SideEffect.Create("audit", "Fallback approval branch selected.") + } + } + : PolicyDecision.Deny($"Fallback approval branch rejected the release; found {approvals} approvals.", Name); + } + + private static int GetInt32(IReadOnlyDictionary metadata, string key) + => metadata.TryGetValue(key, out var value) && value is int number ? number : 0; +} diff --git a/Examples/Core/PolicyComposition/Policies/Emergency/EmergencyOverridePolicy.cs b/Examples/Core/PolicyComposition/Policies/Emergency/EmergencyOverridePolicy.cs new file mode 100644 index 0000000..9656915 --- /dev/null +++ b/Examples/Core/PolicyComposition/Policies/Emergency/EmergencyOverridePolicy.cs @@ -0,0 +1,67 @@ +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using PolicyComposition.State; + +namespace PolicyComposition.Policies.Emergency; + +/// +/// Enables a hard emergency override when the request explicitly asks for it. +/// +/// +/// This policy demonstrates a decisive allow-or-deny branch inside an AnyOf +/// composition. It reads the emergency flag from mutation metadata, and when the +/// flag is present, it promotes the release to an emergency-approved stage and +/// adds a critical side effect for audit visibility. +/// +internal sealed class EmergencyOverridePolicy : IMutationPolicy +{ + /// + /// Policy identifier used in metadata and logs. + /// + public string Name => "EmergencyOverride"; + + /// + /// Higher than the fallback branch, so the explicit override wins first. + /// + public int Priority => 400; + + /// + /// Describes the emergency override behavior. + /// + public string Description => "Allows an emergency release override when requested explicitly."; + + /// + /// Reads the emergency flag from metadata and either approves the override or denies it. + /// + /// The mutation being evaluated. + /// The current release state. + /// + /// An allowed override decision when the emergency flag is set, otherwise a + /// standard denial that allows the AnyOf composition to keep searching. + /// + public PolicyDecision Evaluate(IMutation mutation, ReleaseGateState state) + { + var emergency = GetBool(mutation.Context.Metadata, "emergency"); + + return emergency + ? new PolicyDecision + { + IsAllowed = true, + PolicyName = Name, + Modifications = new Dictionary + { + ["State"] = state with { Stage = "EmergencyApproved" }, + ["SideEffect"] = SideEffect.Critical("release.override", "Emergency override applied.") + }, + Metadata = new Dictionary + { + ["override"] = true + } + } + : PolicyDecision.Deny("Emergency override not requested.", Name); + } + + private static bool GetBool(IReadOnlyDictionary metadata, string key) + => metadata.TryGetValue(key, out var value) && value is true; +} diff --git a/Examples/Core/PolicyComposition/Policies/ReleaseGovernancePolicies.cs b/Examples/Core/PolicyComposition/Policies/ReleaseGovernancePolicies.cs new file mode 100644 index 0000000..2be809a --- /dev/null +++ b/Examples/Core/PolicyComposition/Policies/ReleaseGovernancePolicies.cs @@ -0,0 +1,109 @@ +using ModularityKit.Mutator.Abstractions.Policies; +using PolicyComposition.Policies.Approval; +using PolicyComposition.Policies.Deployment; +using PolicyComposition.Policies.Emergency; +using PolicyComposition.Policies.Shared; +using PolicyComposition.State; + +namespace PolicyComposition.Policies; + +/// +/// Named composed policy sets used by the release governance example. +/// +/// +/// This class acts as the composition root for the example. It keeps the concrete +/// child policies isolated from the scenarios and exposes reusable policy sets that +/// demonstrate the three composition modes: +/// +/// AllOf for mandatory approval gates. +/// AnyOf for emergency fallback flows. +/// Priority for deterministic ordered selection. +/// +/// +internal static class ReleaseGovernancePolicies +{ + /// + /// Builds the standard approval gate used for release promotion. + /// + /// + /// The gate combines: + /// + /// to enforce the approval threshold. + /// to add traceability once the gate succeeds. + /// + /// The composed policy only succeeds when both child policies can contribute + /// without a conflict and the approval requirement is satisfied. + /// + public static IMutationPolicy ApprovalGate() => + ModularityKit.Mutator.Abstractions.Policies.PolicyComposition.AllOf( + "ReleaseApprovalGate", + [ + new RequireApprovalsPolicy(), + new AddAuditTrailPolicy() + ], + priority: 500, + description: "Requires two approvals and adds audit metadata."); + + /// + /// Builds the emergency gate that prefers an explicit override and falls back to approvals. + /// + /// + /// The gate combines: + /// + /// for explicit emergency approval. + /// for the approval-based fallback path. + /// + /// The first allowed branch wins, which makes the result deterministic while + /// still allowing a reusable emergency path to be expressed in one place. + /// + public static IMutationPolicy EmergencyGate() => + ModularityKit.Mutator.Abstractions.Policies.PolicyComposition.AnyOf( + "ReleaseEmergencyGate", + [ + new EmergencyOverridePolicy(), + new ApprovalFallbackPolicy() + ], + priority: 400, + description: "Chooses the emergency override branch when available."); + + /// + /// Builds the deployment gate that evaluates production checks before the default path. + /// + /// + /// The gate combines: + /// + /// to block production releases early. + /// as the fallback branch for non-production releases. + /// + /// Because this is a priority composition, the first decisive policy short-circuits + /// the rest of the chain. + /// + public static IMutationPolicy DeploymentGate() => + ModularityKit.Mutator.Abstractions.Policies.PolicyComposition.Priority( + "ReleaseDeploymentGate", + [ + new ProductionGuardPolicy(), + new DefaultDeploymentPolicy() + ], + priority: 300, + description: "Uses a production guard first, then falls back to the default deployment path."); + + /// + /// Builds a composed gate that intentionally conflicts on the owner field. + /// + /// + /// The gate composes two instances that target the + /// same state field with different values. The example uses this to show that + /// the composition layer detects conflicting mutation results explicitly instead + /// of silently picking one branch. + /// + public static IMutationPolicy ConflictingOwnerGate() => + ModularityKit.Mutator.Abstractions.Policies.PolicyComposition.AllOf( + "ReleaseOwnerConflictGate", + [ + new SetOwnerPolicy("platform"), + new SetOwnerPolicy("security") + ], + priority: 200, + description: "Demonstrates explicit conflict handling when two policies set the same field differently."); +} diff --git a/Examples/Core/PolicyComposition/Policies/Shared/SetOwnerPolicy.cs b/Examples/Core/PolicyComposition/Policies/Shared/SetOwnerPolicy.cs new file mode 100644 index 0000000..4471111 --- /dev/null +++ b/Examples/Core/PolicyComposition/Policies/Shared/SetOwnerPolicy.cs @@ -0,0 +1,48 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using PolicyComposition.State; + +namespace PolicyComposition.Policies.Shared; + +/// +/// Forces the release owner to fixed value, so conflict handling is easy to observe. +/// +/// +/// The policy only touches one field. That makes it useful for the conflict +/// example because two instances of the same class can be composed and produce a +/// deterministic clash when they disagree on the owner. +/// +internal sealed class SetOwnerPolicy(string owner) : IMutationPolicy +{ + /// + /// Policy identifier that includes the target owner value. + /// + public string Name => $"SetOwner[{owner}]"; + + /// + /// Middle priority because the policy is only used as a composed leaf rule. + /// + public int Priority => 150; + + /// + /// Describes the owner assignment performed by the policy. + /// + public string Description => "Sets the release owner."; + + /// + /// Updates only the owner field and leaves the rest of the state unchanged. + /// + /// The mutation being evaluated. + /// The current release state. + /// An allowed decision that changes only the owner field. + public PolicyDecision Evaluate(IMutation mutation, ReleaseGateState state) + => new() + { + IsAllowed = true, + PolicyName = Name, + Modifications = new Dictionary + { + ["State"] = state with { Owner = owner } + } + }; +} From 5e6814a1dd054378cc79e98a534918eacf602c13 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 6 Jul 2026 00:37:24 +0200 Subject: [PATCH 08/10] Feat: Add soutions for composition example --- .../Mutations/SubmitReleaseMutation.cs | 48 +++++++++++++++++++ .../PolicyComposition.csproj | 17 +++++++ Examples/Core/PolicyComposition/Program.cs | 22 +++++++++ 3 files changed, 87 insertions(+) create mode 100644 Examples/Core/PolicyComposition/Mutations/SubmitReleaseMutation.cs create mode 100644 Examples/Core/PolicyComposition/PolicyComposition.csproj create mode 100644 Examples/Core/PolicyComposition/Program.cs diff --git a/Examples/Core/PolicyComposition/Mutations/SubmitReleaseMutation.cs b/Examples/Core/PolicyComposition/Mutations/SubmitReleaseMutation.cs new file mode 100644 index 0000000..fd1baa0 --- /dev/null +++ b/Examples/Core/PolicyComposition/Mutations/SubmitReleaseMutation.cs @@ -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; + +/// +/// Submits a release and carries the governance metadata consumed by the policies. +/// +/// +/// 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. +/// +internal sealed class SubmitReleaseMutation( + string releaseName, + int approvals, + bool emergency, + string environment) : MutationBase( + 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 + { + ["approvals"] = approvals, + ["emergency"] = emergency, + ["environment"] = environment + } + }) +{ + /// + /// Marks the release as submitted before policy composition evaluates it. + /// + /// The current release state. + /// A mutation result that moves the release into the submitted stage. + public override MutationResult Apply(ReleaseGateState state) + => Success( + state with { Stage = "Submitted" }, + StateChange.Modified("Stage", state.Stage, "Submitted")); +} diff --git a/Examples/Core/PolicyComposition/PolicyComposition.csproj b/Examples/Core/PolicyComposition/PolicyComposition.csproj new file mode 100644 index 0000000..e9b36cc --- /dev/null +++ b/Examples/Core/PolicyComposition/PolicyComposition.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + true + $(NoWarn);1591 + false + + + + + + + diff --git a/Examples/Core/PolicyComposition/Program.cs b/Examples/Core/PolicyComposition/Program.cs new file mode 100644 index 0000000..3b22141 --- /dev/null +++ b/Examples/Core/PolicyComposition/Program.cs @@ -0,0 +1,22 @@ +using PolicyComposition.Scenarios; + +namespace PolicyCompositionExample; + +/// +/// Entry point for the policy composition example. +/// +internal static class Program +{ + /// + /// Runs the four sample scenarios for composed governance policies. + /// + private static async Task Main() + { + Console.WriteLine("=== Policy Composition Example ===\n"); + + await AllOfScenario.Run(); + await AnyOfScenario.Run(); + await PriorityScenario.Run(); + await ConflictScenario.Run(); + } +} From a238615611c6af84b6dcb55c8e458e98d20be2fd Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 6 Jul 2026 00:39:42 +0200 Subject: [PATCH 09/10] Feat: Add PolicyComposition example for AllOf AnyOf and priority Introduce a new PolicyComposition example demonstrating reusable composed policies (AllOf, AnyOf, priority) and explicit conflict handling. Added example README, ReleaseGateState model, and four scenario programs (AllOf, AnyOf, Priority, Conflict). Also updated Examples/README.md to include build/run instructions for the new example. --- Examples/Core/PolicyComposition/README.md | 55 +++++++++++++++ .../Scenarios/AllOfScenario.cs | 70 +++++++++++++++++++ .../Scenarios/AnyOfScenario.cs | 61 ++++++++++++++++ .../Scenarios/ConflictScenario.cs | 47 +++++++++++++ .../Scenarios/PriorityScenario.cs | 64 +++++++++++++++++ .../State/ReleaseGateState.cs | 45 ++++++++++++ Examples/README.md | 9 +++ 7 files changed, 351 insertions(+) create mode 100644 Examples/Core/PolicyComposition/README.md create mode 100644 Examples/Core/PolicyComposition/Scenarios/AllOfScenario.cs create mode 100644 Examples/Core/PolicyComposition/Scenarios/AnyOfScenario.cs create mode 100644 Examples/Core/PolicyComposition/Scenarios/ConflictScenario.cs create mode 100644 Examples/Core/PolicyComposition/Scenarios/PriorityScenario.cs create mode 100644 Examples/Core/PolicyComposition/State/ReleaseGateState.cs diff --git a/Examples/Core/PolicyComposition/README.md b/Examples/Core/PolicyComposition/README.md new file mode 100644 index 0000000..f8f261f --- /dev/null +++ b/Examples/Core/PolicyComposition/README.md @@ -0,0 +1,55 @@ +# PolicyComposition + +This example shows how to compose multiple governance policies into reusable sets. + +It is the sample to read when you want to see `AllOf`, `AnyOf`, and priority-based composition +without pushing the rules into one large handwritten policy class. + +## Domain + +The sample uses a simple release gate model: + +- a release has a name, stage, and owner +- policy metadata carries approvals, environment, and emergency flags +- composed policies decide whether the release stays blocked, becomes approved, or gets an emergency path + +## What this example demonstrates + +- reusable composed policy sets +- explicit merge rules for severity, requirements, side effects, and metadata +- `AllOf`, `AnyOf`, and priority-based composition +- deterministic conflict handling when two policies modify the same value + +## Project structure + +- [`Program.cs`](Program.cs) +- [`PolicyComposition.csproj`](PolicyComposition.csproj) +- [`State/ReleaseGateState.cs`](State/ReleaseGateState.cs) +- [`Mutations/SubmitReleaseMutation.cs`](Mutations/SubmitReleaseMutation.cs) +- [`Policies/ReleaseGovernancePolicies.cs`](Policies/ReleaseGovernancePolicies.cs) +- [`Policies/Approval/RequireApprovalsPolicy.cs`](Policies/Approval/RequireApprovalsPolicy.cs) +- [`Policies/Approval/AddAuditTrailPolicy.cs`](Policies/Approval/AddAuditTrailPolicy.cs) +- [`Policies/Emergency/EmergencyOverridePolicy.cs`](Policies/Emergency/EmergencyOverridePolicy.cs) +- [`Policies/Emergency/ApprovalFallbackPolicy.cs`](Policies/Emergency/ApprovalFallbackPolicy.cs) +- [`Policies/Deployment/ProductionGuardPolicy.cs`](Policies/Deployment/ProductionGuardPolicy.cs) +- [`Policies/Deployment/DefaultDeploymentPolicy.cs`](Policies/Deployment/DefaultDeploymentPolicy.cs) +- [`Policies/Shared/SetOwnerPolicy.cs`](Policies/Shared/SetOwnerPolicy.cs) +- [`Scenarios/AllOfScenario.cs`](Scenarios/AllOfScenario.cs) +- [`Scenarios/AnyOfScenario.cs`](Scenarios/AnyOfScenario.cs) +- [`Scenarios/PriorityScenario.cs`](Scenarios/PriorityScenario.cs) +- [`Scenarios/ConflictScenario.cs`](Scenarios/ConflictScenario.cs) + +## Run + +```bash +dotnet run --project Examples/Core/PolicyComposition/PolicyComposition.csproj +``` + +## Expected output + +You should see: + +- an `AllOf` release gate that merges state updates, metadata, and audit side effects +- an `AnyOf` gate that selects the first allowed branch +- a priority-based gate that prefers the first decisive policy +- a conflict example that raises `PolicyCompositionConflictException` diff --git a/Examples/Core/PolicyComposition/Scenarios/AllOfScenario.cs b/Examples/Core/PolicyComposition/Scenarios/AllOfScenario.cs new file mode 100644 index 0000000..cc794a6 --- /dev/null +++ b/Examples/Core/PolicyComposition/Scenarios/AllOfScenario.cs @@ -0,0 +1,70 @@ +using ModularityKit.Mutator.Abstractions.Policies; +using PolicyComposition.Mutations; +using PolicyComposition.Policies; +using PolicyComposition.State; + +namespace PolicyComposition.Scenarios; + +/// +/// Demonstrates the AllOf composition mode for reusable governance gates. +/// +/// +/// This scenario is intentionally small: it creates a release state, builds a +/// release submission mutation, evaluates the approval gate, and prints the +/// composed result. The goal is to show how the merged decision contains the +/// final state, audit side effects, and composition metadata. +/// +internal static class AllOfScenario +{ + /// + /// Evaluates the approval gate against a sample release submission. + /// + /// + /// The mutation carries approval count, emergency flag, and environment data + /// through its execution context so the composed policies can make their own + /// decisions without needing extra helper state. + /// + public static async Task Run() + { + var state = new ReleaseGateState("release-42", "Draft", "platform"); + var mutation = new SubmitReleaseMutation( + releaseName: state.ReleaseName, + approvals: 2, + emergency: false, + environment: "staging"); + + var decision = await ReleaseGovernancePolicies.ApprovalGate().EvaluateAsync(mutation, state); + + WriteDecision(decision); + } + + /// + /// Writes the important parts of the composed decision to the console. + /// + /// The composed policy decision returned by the gate. + private static void WriteDecision(PolicyDecision decision) + { + Console.WriteLine("[AnyOf] Reusable override gate"); + + Console.WriteLine($" allowed: {decision.IsAllowed}"); + Console.WriteLine($" reason: {decision.Reason}"); + Console.WriteLine($" mode: {decision.Metadata!["PolicyComposition.Mode"]}"); + + if (decision.Modifications is not null && + decision.Modifications.TryGetValue("State", out var value) && + value is ReleaseGateState state) + { + Console.WriteLine($" stage: {state.Stage}"); + Console.WriteLine($" owner: {state.Owner}"); + } + + if (decision.Modifications is not null && + decision.Modifications.TryGetValue("SideEffects", out var sideEffects) && + sideEffects is IEnumerable effects) + { + Console.WriteLine($" sideEffects: {effects.Count()}"); + } + + Console.WriteLine(); + } +} diff --git a/Examples/Core/PolicyComposition/Scenarios/AnyOfScenario.cs b/Examples/Core/PolicyComposition/Scenarios/AnyOfScenario.cs new file mode 100644 index 0000000..0b07108 --- /dev/null +++ b/Examples/Core/PolicyComposition/Scenarios/AnyOfScenario.cs @@ -0,0 +1,61 @@ +using ModularityKit.Mutator.Abstractions.Policies; +using PolicyComposition.Mutations; +using PolicyComposition.Policies; +using PolicyComposition.State; + +namespace PolicyComposition.Scenarios; + +/// +/// Demonstrates the AnyOf composition mode for alternate governance branches. +/// +/// +/// This scenario evaluates the emergency gate against a sample release submission. +/// The setup is deliberately asymmetric: the emergency flag is enabled, so the +/// composed gate can show how one allowed branch wins while the blocked branch is +/// still visible in the output metadata. +/// +internal static class AnyOfScenario +{ + /// + /// Evaluates the emergency gate and prints the winning and blocking branches. + /// + /// + /// The sample mutation carries governance metadata through the execution + /// context. That allows the emergency override policy to detect the explicit + /// override flag while the fallback branch remains available as a reusable + /// alternative path. + /// + public static async Task Run() + { + var state = new ReleaseGateState("release-42", "Draft", "platform"); + var mutation = new SubmitReleaseMutation( + releaseName: state.ReleaseName, + approvals: 0, + emergency: true, + environment: "staging"); + + var decision = await ReleaseGovernancePolicies.EmergencyGate().EvaluateAsync(mutation, state); + + WriteDecision(decision); + } + + /// + /// Writes the merged result of the emergency gate to the console. + /// + /// The composed policy decision returned by the gate. + private static void WriteDecision(PolicyDecision decision) + { + Console.WriteLine("[AnyOf] Emergency override gate"); + Console.WriteLine($" allowed: {decision.IsAllowed}"); + Console.WriteLine($" reason: {decision.Reason}"); + Console.WriteLine($" winning: {string.Join(", ", (string[])decision.Metadata!["PolicyComposition.WinningPolicies"])}"); + Console.WriteLine($" blocking: {string.Join(", ", (string[])decision.Metadata!["PolicyComposition.BlockingPolicies"])}"); + + if (decision.Modifications is not null && decision.Modifications.TryGetValue("State", out var value) && value is ReleaseGateState state) + { + Console.WriteLine($" stage: {state.Stage}"); + } + + Console.WriteLine(); + } +} diff --git a/Examples/Core/PolicyComposition/Scenarios/ConflictScenario.cs b/Examples/Core/PolicyComposition/Scenarios/ConflictScenario.cs new file mode 100644 index 0000000..7eab2fc --- /dev/null +++ b/Examples/Core/PolicyComposition/Scenarios/ConflictScenario.cs @@ -0,0 +1,47 @@ +using ModularityKit.Mutator.Abstractions.Exceptions; +using PolicyComposition.Mutations; +using PolicyComposition.Policies; +using PolicyComposition.State; + +namespace PolicyComposition.Scenarios; + +/// +/// Demonstrates explicit conflict detection during composition. +/// +/// +/// This scenario intentionally composes two policies that both write the owner +/// field with different values. The example is useful because it shows that the +/// composition layer does not silently pick one result. Instead, it fails fast +/// with a conflict exception that names the field and the policies involved. +/// +internal static class ConflictScenario +{ + /// + /// Evaluates a conflicting policy set and prints the exception details. + /// + /// + /// The exception is caught locally so the example can show the conflict + /// diagnostics without stopping the rest of the console run. + /// + public static async Task Run() + { + var state = new ReleaseGateState("release-42", "Draft", "platform"); + var mutation = new SubmitReleaseMutation( + releaseName: state.ReleaseName, + approvals: 2, + emergency: false, + environment: "staging"); + + try + { + await ReleaseGovernancePolicies.ConflictingOwnerGate().EvaluateAsync(mutation, state); + } + catch (PolicyCompositionConflictException exception) + { + Console.WriteLine($" conflict key: {exception.ConflictKey}"); + Console.WriteLine($" policies: {string.Join(", ", exception.PolicyNames)}"); + Console.WriteLine($" message: {exception.Message}"); + Console.WriteLine(); + } + } +} diff --git a/Examples/Core/PolicyComposition/Scenarios/PriorityScenario.cs b/Examples/Core/PolicyComposition/Scenarios/PriorityScenario.cs new file mode 100644 index 0000000..399894f --- /dev/null +++ b/Examples/Core/PolicyComposition/Scenarios/PriorityScenario.cs @@ -0,0 +1,64 @@ +using ModularityKit.Mutator.Abstractions.Policies; +using PolicyComposition.Mutations; +using PolicyComposition.Policies; +using PolicyComposition.State; + +namespace PolicyComposition.Scenarios; + +/// +/// Demonstrates the priority-based composition mode for deterministic policy selection. +/// +/// +/// This scenario evaluates the deployment gate twice: once for a staging release +/// and once for a production release. The first path shows the fallback branch +/// being selected, while the second path shows the guard policy short-circuiting +/// the composition with a decisive denial. +/// +internal static class PriorityScenario +{ + /// + /// Evaluates the deployment gate for staging and production inputs. + /// + /// + /// The two inputs only differ by environment, which keeps the example focused + /// on priority ordering rather than on unrelated mutation state. + /// + public static async Task Run() + { + var state = new ReleaseGateState("release-42", "Draft", "platform"); + + var stagingMutation = new SubmitReleaseMutation( + releaseName: state.ReleaseName, + approvals: 1, + emergency: false, + environment: "staging"); + + var productionMutation = new SubmitReleaseMutation( + releaseName: state.ReleaseName, + approvals: 1, + emergency: false, + environment: "production"); + + Console.WriteLine(" staging path:"); + WriteDecision(await ReleaseGovernancePolicies.DeploymentGate().EvaluateAsync(stagingMutation, state)); + + Console.WriteLine(" production path:"); + WriteDecision(await ReleaseGovernancePolicies.DeploymentGate().EvaluateAsync(productionMutation, state)); + } + + /// + /// Writes the outcome of the priority-based composition. + /// + /// The composed policy decision returned by the gate. + private static void WriteDecision(PolicyDecision decision) + { + Console.WriteLine($" allowed: {decision.IsAllowed}"); + Console.WriteLine($" reason: {decision.Reason}"); + Console.WriteLine($" selected: {string.Join(", ", (string[])decision.Metadata!["PolicyComposition.WinningPolicies"])}"); + + if (decision.Modifications is not null && decision.Modifications.TryGetValue("State", out var value) && value is ReleaseGateState state) + { + Console.WriteLine($" stage: {state.Stage}"); + } + } +} diff --git a/Examples/Core/PolicyComposition/State/ReleaseGateState.cs b/Examples/Core/PolicyComposition/State/ReleaseGateState.cs new file mode 100644 index 0000000..6e4567d --- /dev/null +++ b/Examples/Core/PolicyComposition/State/ReleaseGateState.cs @@ -0,0 +1,45 @@ +namespace PolicyComposition.State; + +/// +/// Minimal release state used to demonstrate governance policy composition. +/// +/// +/// The example keeps the state intentionally small so the composition behavior +/// is easy to follow. Each field maps directly to a visible part of the policy +/// output: +/// +/// identifies the release flow. +/// shows which policy branch updated the release. +/// is used by the conflict example. +/// +/// +public sealed record ReleaseGateState +{ + /// + /// Creates a new release state. + /// + /// The release identifier. + /// The initial release stage. + /// The initial release owner. + public ReleaseGateState(string releaseName, string stage, string owner) + { + ReleaseName = releaseName; + Stage = stage; + Owner = owner; + } + + /// + /// The release identifier used to correlate the example flow. + /// + public string ReleaseName { get; init; } + + /// + /// The current release stage. + /// + public string Stage { get; init; } + + /// + /// The current release owner. + /// + public string Owner { get; init; } +} diff --git a/Examples/README.md b/Examples/README.md index 443d047..fdfcbf7 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -16,6 +16,7 @@ The projects are intentionally small and focused. Each one demonstrates a differ | `BillingQuotas` | quota changes, validation, and policy limits | [`Examples/Core/BillingQuotas/README.md`](Core/BillingQuotas/README.md) | | `FeatureFlags` | feature toggles, audit/history, and batch execution | [`Examples/Core/FeatureFlags/README.md`](Core/FeatureFlags/README.md) | | `IamRoles` | role changes, approval rules, and batch migration | [`Examples/Core/IamRoles/README.md`](Core/IamRoles/README.md) | +| `PolicyComposition` | reusable policy sets, merge rules, and conflict handling | [`Examples/Core/PolicyComposition/README.md`](Core/PolicyComposition/README.md) | | `WorkflowApprovals` | ordered workflow state transitions and approvals | [`Examples/Core/WorkflowApprovals/README.md`](Core/WorkflowApprovals/README.md) | ## Governance examples @@ -55,6 +56,7 @@ You can also build just one example: dotnet build Examples/Core/BillingQuotas/BillingQuotas.csproj -c Release dotnet build Examples/Core/FeatureFlags/FeatureFlags.csproj -c Release dotnet build Examples/Core/IamRoles/IamRoles.csproj -c Release +dotnet build Examples/Core/PolicyComposition/PolicyComposition.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/GovernedExecution/GovernedExecution.csproj -c Release @@ -75,6 +77,7 @@ From the repository root: dotnet run --project Examples/Core/BillingQuotas/BillingQuotas.csproj dotnet run --project Examples/Core/FeatureFlags/FeatureFlags.csproj dotnet run --project Examples/Core/IamRoles/IamRoles.csproj +dotnet run --project Examples/Core/PolicyComposition/PolicyComposition.csproj dotnet run --project Examples/Core/WorkflowApprovals/WorkflowApprovals.csproj dotnet run --project Examples/Governance/RequestLifecycle/RequestLifecycle.csproj dotnet run --project Examples/Governance/GovernedExecution/GovernedExecution.csproj @@ -134,6 +137,12 @@ Shows role grant and revoke workflows with approval-style rules. This is the exa See [`Core/IamRoles/README.md`](Core/IamRoles/README.md). +### PolicyComposition + +Shows how to reuse governance policy sets with deterministic merge rules and explicit conflict handling. This is the example to read if you want `AllOf`, `AnyOf`, and priority-based composition without writing one large bespoke policy class. + +See [`Core/PolicyComposition/README.md`](Core/PolicyComposition/README.md). + ### WorkflowApprovals Shows a multi-step approval process with ordered execution and rejection handling. This example is the best fit if you want to study state transitions that must happen in a strict sequence. From 6c62c1d6d608f899756887a7765ebf123a487772 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Mon, 6 Jul 2026 00:54:06 +0200 Subject: [PATCH 10/10] Feat: Add PolicyComposition solution and test policies --- Docs/Roadmap.md | 2 +- ModularityKit.Mutator.slnx | 1 + .../Policies/PolicyCompositionTests.cs | 131 ++++++++++++++++++ .../Composition/AllowedStatePolicy.cs | 47 +++++++ .../Policies/Composition/ApprovalPolicy.cs | 46 ++++++ .../Policies/Composition/BlockingPolicy.cs | 39 ++++++ .../Composition/HighPriorityStatePolicy.cs | 54 ++++++++ .../Composition/LowPriorityStatePolicy.cs | 51 +++++++ .../Policies/Composition/MetadataPolicy.cs | 50 +++++++ .../SideEffectsAndMetadataPolicy.cs | 55 ++++++++ .../Composition/StateAndSideEffectPolicy.cs | 54 ++++++++ .../Policies/Composition/StatePolicy.cs | 47 +++++++ src/README.md | 14 ++ 13 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 Tests/ModularityKit.Mutator.Tests/Runtime/Policies/PolicyCompositionTests.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/AllowedStatePolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/ApprovalPolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/BlockingPolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/HighPriorityStatePolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/LowPriorityStatePolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/MetadataPolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/SideEffectsAndMetadataPolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/StateAndSideEffectPolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/StatePolicy.cs diff --git a/Docs/Roadmap.md b/Docs/Roadmap.md index b2724fe..a458b23 100644 --- a/Docs/Roadmap.md +++ b/Docs/Roadmap.md @@ -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: diff --git a/ModularityKit.Mutator.slnx b/ModularityKit.Mutator.slnx index 58d53f4..31f6a27 100644 --- a/ModularityKit.Mutator.slnx +++ b/ModularityKit.Mutator.slnx @@ -8,6 +8,7 @@ + diff --git a/Tests/ModularityKit.Mutator.Tests/Runtime/Policies/PolicyCompositionTests.cs b/Tests/ModularityKit.Mutator.Tests/Runtime/Policies/PolicyCompositionTests.cs new file mode 100644 index 0000000..2d36b13 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/Runtime/Policies/PolicyCompositionTests.cs @@ -0,0 +1,131 @@ +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Exceptions; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; +using ModularityKit.Mutator.Tests.TestSupport.Mutations; +using ModularityKit.Mutator.Tests.TestSupport.Policies.Composition; +using Xunit; + +namespace ModularityKit.Mutator.Tests.Runtime.Policies; + +public sealed class PolicyCompositionTests +{ + [Fact] + public async Task AllOf_merges_modifications_side_effects_and_metadata() + { + var composed = PolicyComposition.AllOf( + "FeatureFlagGovernance", + [ + new StateAndSideEffectPolicy(), + new SideEffectsAndMetadataPolicy() + ], + priority: 500, + description: "Composed governance rules for feature flag changes."); + + var decision = await composed.EvaluateAsync(new PolicySampleMutation(), new PolicySampleState("initial")); + + Assert.True(decision.IsAllowed); + Assert.Equal("governed", GetState(decision).Value); + Assert.Equal(2, GetSideEffects(decision).Count()); + Assert.Equal("FeatureFlagGovernance", decision.PolicyName); + Assert.Equal("AllOf", decision.Metadata!["PolicyComposition.Mode"]); + Assert.Equal("state-policy", decision.Metadata!["owner"]); + Assert.Equal("state-policy", decision.Metadata!["source"]); + } + + [Fact] + public async Task AllOf_merges_requirements_and_metadata() + { + var composed = PolicyComposition.AllOf( + "ApprovalGate", + [ + new ApprovalPolicy(), + new MetadataPolicy() + ], + priority: 100); + + var decision = await composed.EvaluateAsync( + new PolicySampleMutation(), + new PolicySampleState("initial") + ); + + Assert.False(decision.IsAllowed); + Assert.Single(decision.Requirements!); + Assert.Equal("Approval", decision.Requirements![0].Type); + Assert.Equal(PolicyDecisionSeverity.Error, decision.Severity); + Assert.Equal("ApprovalGate", decision.PolicyName); + Assert.Equal("AllOf", decision.Metadata!["PolicyComposition.Mode"]); + Assert.Equal("compliance", decision.Metadata!["team"]); + Assert.Equal("platform", decision.Metadata!["owner"]); + Assert.Equal(["ApprovalPolicy", "MetadataPolicy"], (string[])decision.Metadata["PolicyComposition.BlockingPolicies"]); + } + + [Fact] + public async Task AnyOf_uses_only_allowed_branches_when_one_branch_succeeds() + { + var composed = PolicyComposition.AnyOf( + "AlternativeAllow", + [ + new BlockingPolicy(), + new AllowedStatePolicy() + ], + priority: 100); + + var decision = await composed.EvaluateAsync(new PolicySampleMutation(), new PolicySampleState("initial")); + + Assert.True(decision.IsAllowed); + Assert.NotNull(decision.Modifications); + Assert.Equal("AlternativeAllow", decision.PolicyName); + Assert.Equal("AnyOf", decision.Metadata!["PolicyComposition.Mode"]); + Assert.Equal(["AllowedStatePolicy"], (string[])decision.Metadata["PolicyComposition.WinningPolicies"]); + Assert.Equal(["BlockingPolicy"], (string[])decision.Metadata["PolicyComposition.BlockingPolicies"]); + Assert.Equal("allowed", GetState(decision).Value); + } + + [Fact] + public async Task Priority_composition_returns_first_decisive_higher_priority_policy() + { + var composed = PolicyComposition.Priority( + "PriorityGate", + [ + new LowPriorityStatePolicy(), + new HighPriorityStatePolicy() + ], + priority: 100); + + var decision = await composed.EvaluateAsync(new PolicySampleMutation(), new PolicySampleState("initial")); + + Assert.True(decision.IsAllowed); + Assert.Equal("PriorityGate", decision.PolicyName); + Assert.Equal("high", GetState(decision).Value); + Assert.Single(GetSideEffects(decision)); + Assert.Equal("high", decision.Metadata!["selectedPolicy"]); + } + + [Fact] + public async Task AllOf_detects_conflicting_mutation_result_modifications() + { + var composed = PolicyComposition.AllOf( + "ConflictingGate", + [ + new StatePolicy("First", "one"), + new StatePolicy("Second", "two") + ], + priority: 100); + + var exception = await Assert.ThrowsAsync(() => + composed.EvaluateAsync(new PolicySampleMutation(), new PolicySampleState("initial"))); + + Assert.Equal("ConflictingGate", exception.CompositionName); + Assert.Equal("State", exception.ConflictKey); + Assert.Equal(["First", "Second"], exception.PolicyNames); + } + + private static PolicySampleState GetState(PolicyDecision decision) + => (PolicySampleState)decision.Modifications!["State"]; + + private static IEnumerable GetSideEffects(PolicyDecision decision) + => (IEnumerable)decision.Modifications!["SideEffects"]; + +} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/AllowedStatePolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/AllowedStatePolicy.cs new file mode 100644 index 0000000..e0fc5e0 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/AllowedStatePolicy.cs @@ -0,0 +1,47 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition; + +/// +/// Test policy that allows the mutation and replaces the sample state value. +/// +/// +/// Used by policy composition tests to verify that state modifications from +/// allowed policies are propagated correctly through composed decisions. +/// +internal sealed class AllowedStatePolicy : IMutationPolicy +{ + /// + /// Gets the policy name. + /// + public string Name => "AllowedStatePolicy"; + + /// + /// Gets the evaluation priority. + /// + public int Priority => 100; + + /// + /// Gets the policy description. + /// + public string Description => "Applies the allowed branch."; + + /// + /// Produces an allowed decision containing a modified sample state. + /// + /// The mutation being evaluated. + /// The current sample state. + /// An allowed decision with replacement state modification. + public PolicyDecision Evaluate(IMutation mutation, PolicySampleState state) + => new() + { + IsAllowed = true, + PolicyName = Name, + Modifications = new Dictionary + { + ["State"] = new PolicySampleState(Value: "allowed") + } + }; +} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/ApprovalPolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/ApprovalPolicy.cs new file mode 100644 index 0000000..aa596e7 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/ApprovalPolicy.cs @@ -0,0 +1,46 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition; + +/// +/// Test policy that blocks execution and requires explicit approval. +/// +/// +/// Used by policy composition tests to verify approval requirements, +/// blocking decisions, and requirement aggregation. +/// +internal sealed class ApprovalPolicy : IMutationPolicy +{ + /// + /// Gets the policy name. + /// + public string Name => "ApprovalPolicy"; + + /// + /// Gets the evaluation priority. + /// + public int Priority => 200; + + /// + /// Gets the policy description. + /// + public string Description => "Requires signoff for sensitive work."; + + /// + /// Produces a blocking decision that requires explicit approval. + /// + /// The mutation being evaluated. + /// The current sample state. + /// A blocking decision with an approval requirement. + public PolicyDecision Evaluate(IMutation mutation, PolicySampleState state) + => new() + { + IsAllowed = false, + PolicyName = Name, + Severity = PolicyDecisionSeverity.Error, + Reason = "Sensitive change requires approval.", + Requirements = [PolicyRequirement.Approval("approver-a", "Sensitive change requires approval.")] + }; +} \ No newline at end of file diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/BlockingPolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/BlockingPolicy.cs new file mode 100644 index 0000000..c2f70b0 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/BlockingPolicy.cs @@ -0,0 +1,39 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition; + +/// +/// Test policy that unconditionally blocks mutation execution. +/// +/// +/// Used by policy composition tests to verify blocking behavior, +/// precedence rules, and denial propagation. +/// +internal sealed class BlockingPolicy : IMutationPolicy +{ + /// + /// Gets the policy name. + /// + public string Name => "BlockingPolicy"; + + /// + /// Gets the evaluation priority. + /// + public int Priority => 400; + + /// + /// Gets the policy description. + /// + public string Description => "Rejects the mutation."; + + /// + /// Produces a blocking policy decision. + /// + /// The mutation being evaluated. + /// The current sample state. + /// A denial decision. + public PolicyDecision Evaluate(IMutation mutation, PolicySampleState state) + => PolicyDecision.Deny("Blocked by policy.", Name); +} \ No newline at end of file diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/HighPriorityStatePolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/HighPriorityStatePolicy.cs new file mode 100644 index 0000000..d6f37f8 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/HighPriorityStatePolicy.cs @@ -0,0 +1,54 @@ +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition; + +/// +/// Test policy that contributes high priority state modification. +/// +/// +/// Used by policy composition tests to verify priority selection, +/// metadata propagation, and side effect aggregation. +/// +internal sealed class HighPriorityStatePolicy : IMutationPolicy +{ + /// + /// Gets the policy name. + /// + public string Name => "HighPriorityStatePolicy"; + + /// + /// Gets the evaluation priority. + /// + public int Priority => 500; + + /// + /// Gets the policy description. + /// + public string Description => "Higher priority branch."; + + /// + /// Produces an allowed decision with a high-priority state modification, + /// metadata, and an audit side effect. + /// + /// The mutation being evaluated. + /// The current sample state. + /// An allowed decision with state, metadata, and side effects. + public PolicyDecision Evaluate(IMutation mutation, PolicySampleState state) + => new() + { + IsAllowed = true, + PolicyName = Name, + Modifications = new Dictionary + { + ["State"] = new PolicySampleState(Value: "high"), + ["SideEffect"] = SideEffect.Create("audit", "High priority branch selected.") + }, + Metadata = new Dictionary + { + ["selectedPolicy"] = "high" + } + }; +} \ No newline at end of file diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/LowPriorityStatePolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/LowPriorityStatePolicy.cs new file mode 100644 index 0000000..e5b2d33 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/LowPriorityStatePolicy.cs @@ -0,0 +1,51 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition; + +/// +/// Test policy that contributes low priority state modification. +/// +/// +/// Used by policy composition tests to verify deterministic priority ordering +/// and metadata conflict handling. +/// +internal sealed class LowPriorityStatePolicy : IMutationPolicy +{ + /// + /// Gets the policy name. + /// + public string Name => "LowPriorityStatePolicy"; + + /// + /// Gets the evaluation priority. + /// + public int Priority => 100; + + /// + /// Gets the policy description. + /// + public string Description => "Lower priority branch."; + + /// + /// Produces an allowed decision with a low-priority state modification. + /// + /// The mutation being evaluated. + /// The current sample state. + /// An allowed decision with state and metadata. + public PolicyDecision Evaluate(IMutation mutation, PolicySampleState state) + => new() + { + IsAllowed = true, + PolicyName = Name, + Modifications = new Dictionary + { + ["State"] = new PolicySampleState(Value: "low") + }, + Metadata = new Dictionary + { + ["selectedPolicy"] = Name + } + }; +} \ No newline at end of file diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/MetadataPolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/MetadataPolicy.cs new file mode 100644 index 0000000..4dd2252 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/MetadataPolicy.cs @@ -0,0 +1,50 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition; + +/// +/// Test policy that contributes governance metadata while blocking execution. +/// +/// +/// Used by policy composition tests to verify metadata aggregation, +/// propagation, and conflict detection across composed decisions. +/// +internal sealed class MetadataPolicy : IMutationPolicy +{ + /// + /// Gets the policy name. + /// + public string Name => "MetadataPolicy"; + + /// + /// Gets the evaluation priority. + /// + public int Priority => 150; + + /// + /// Gets the policy description. + /// + public string Description => "Adds governance metadata."; + + /// + /// Produces blocking decision containing governance metadata. + /// + /// The mutation being evaluated. + /// The current sample state. + /// A blocking decision with metadata. + public PolicyDecision Evaluate(IMutation mutation, PolicySampleState state) + => new() + { + IsAllowed = false, + PolicyName = Name, + Reason = "Metadata policy requires approval.", + Severity = PolicyDecisionSeverity.Warning, + Metadata = new Dictionary + { + ["team"] = "compliance", + ["owner"] = "platform" + } + }; +} \ No newline at end of file diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/SideEffectsAndMetadataPolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/SideEffectsAndMetadataPolicy.cs new file mode 100644 index 0000000..382e03e --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/SideEffectsAndMetadataPolicy.cs @@ -0,0 +1,55 @@ +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition; + +/// +/// Test policy that contributes side effects and governance metadata. +/// +/// +/// Used by policy composition tests to verify side effect aggregation and +/// metadata propagation across composed policy decisions. +/// +internal sealed class SideEffectsAndMetadataPolicy : IMutationPolicy +{ + /// + /// Gets the policy name. + /// + public string Name => "SideEffectsAndMetadataPolicy"; + + /// + /// Gets the evaluation priority. + /// + public int Priority => 200; + + /// + /// Gets the policy description. + /// + public string Description => "Adds an audit side effect and governance metadata."; + + /// + /// Produces an allowed decision containing side effects and metadata. + /// + /// The mutation being evaluated. + /// The current sample state. + /// An allowed decision with side effects and metadata. + public PolicyDecision Evaluate(IMutation mutation, PolicySampleState state) + => new() + { + IsAllowed = true, + PolicyName = Name, + Modifications = new Dictionary + { + ["SideEffects"] = new[] + { + SideEffect.Create("notification", "Composed policy emitted a notification.") + } + }, + Metadata = new Dictionary + { + ["owner"] = "state-policy" + } + }; +} \ No newline at end of file diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/StateAndSideEffectPolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/StateAndSideEffectPolicy.cs new file mode 100644 index 0000000..5c70de5 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/StateAndSideEffectPolicy.cs @@ -0,0 +1,54 @@ +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition; + +/// +/// Test policy that contributes state modification and single side effect. +/// +/// +/// Used by policy composition tests to verify state modification merging, +/// side effect aggregation, and metadata propagation. +/// +internal sealed class StateAndSideEffectPolicy : IMutationPolicy +{ + /// + /// Gets the policy name. + /// + public string Name => "StateAndSideEffectPolicy"; + + /// + /// Gets the evaluation priority. + /// + public int Priority => 300; + + /// + /// Gets the policy description. + /// + public string Description => "Moves state and records one side effect."; + + /// + /// Produces an allowed decision containing a state modification, + /// a side effect, and metadata. + /// + /// The mutation being evaluated. + /// The current sample state. + /// An allowed decision with state, side effect, and metadata. + public PolicyDecision Evaluate(IMutation mutation, PolicySampleState state) + => new() + { + IsAllowed = true, + PolicyName = Name, + Modifications = new Dictionary + { + ["State"] = new PolicySampleState(Value: "governed"), + ["SideEffect"] = SideEffect.Create("audit", "State changed by the composed policy set.") + }, + Metadata = new Dictionary + { + ["source"] = "state-policy" + } + }; +} \ No newline at end of file diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/StatePolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/StatePolicy.cs new file mode 100644 index 0000000..0032ffd --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Policies/Composition/StatePolicy.cs @@ -0,0 +1,47 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Policies.Composition; + +/// +/// Test policy that assigns a predefined state value. +/// +/// +/// Used by policy composition tests to verify deterministic state merging +/// and modification conflict detection. +/// +internal sealed class StatePolicy(string name, string value) : IMutationPolicy +{ + /// + /// Gets the policy name. + /// + public string Name => name; + + /// + /// Gets the evaluation priority. + /// + public int Priority => 100; + + /// + /// Gets the policy description. + /// + public string Description => "Sets a fixed state value."; + + /// + /// Produces an allowed decision containing a predefined state value. + /// + /// The mutation being evaluated. + /// The current sample state. + /// An allowed decision with a fixed state modification. + public PolicyDecision Evaluate(IMutation mutation, PolicySampleState state) + => new() + { + IsAllowed = true, + PolicyName = Name, + Modifications = new Dictionary + { + ["State"] = new PolicySampleState(Value: value) + } + }; +} \ No newline at end of file diff --git a/src/README.md b/src/README.md index 8920581..9c86001 100644 --- a/src/README.md +++ b/src/README.md @@ -101,6 +101,8 @@ Core runtime concurrency is controlled by `MutationEngineOptions.MaxConcurrentMu - `IPolicyRegistry` - `PolicyDecision` - `PolicyRequirement` +- `PolicyComposition` +- `PolicyCompositionMode` ### Async policies and external checks @@ -123,6 +125,18 @@ Typical integration cases include: - quota lookups in remote control planes - compliance or risk verification before commit +When policies need to be reused as a set, `PolicyComposition.AllOf(...)`, +`PolicyComposition.AnyOf(...)`, and `PolicyComposition.Priority(...)` provide a deterministic +way to combine decisions without adding a separate rules language. + +Merge rules are explicit: + +- severity uses the highest severity from the selected policy branch +- requirements are preserved and concatenated +- side effects are concatenated +- metadata merges by key and raises `PolicyCompositionConflictException` when two policies try to set the same key to different values +- state and other mutation-result modifications follow the same conflict rule + ```csharp public sealed class RequireApprovedTicketPolicy : IMutationPolicy {