Skip to content

Commit 9cfb37d

Browse files
committed
Feat: policy decision merging and evaluation types
1 parent 50b5b18 commit 9cfb37d

3 files changed

Lines changed: 215 additions & 0 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using ModularityKit.Mutator.Abstractions.Exceptions;
2+
3+
namespace ModularityKit.Mutator.Abstractions.Policies;
4+
5+
/// <summary>
6+
/// Merges metadata for composed policy decisions.
7+
/// </summary>
8+
/// <remarks>
9+
/// The merger preserves composition audit metadata and folds child policy
10+
/// metadata into the final decision. Metadata keys are merged deterministically.
11+
/// When two child policies provide the same metadata key with different values,
12+
/// the merge fails with policy composition conflict so ambiguous audit data is
13+
/// never silently overwritten.
14+
/// </remarks>
15+
internal static class PolicyDecisionMetadataMerger
16+
{
17+
/// <summary>
18+
/// The metadata key used to store the policy composition mode.
19+
/// </summary>
20+
private const string CompositionModeKey = "PolicyComposition.Mode";
21+
22+
/// <summary>
23+
/// The metadata key used to store the ordered child policy evaluation summary.
24+
/// </summary>
25+
private const string CompositionPoliciesKey = "PolicyComposition.Policies";
26+
27+
/// <summary>
28+
/// The metadata key used to store the policies that contributed to the composed result.
29+
/// </summary>
30+
private const string CompositionWinningPoliciesKey = "PolicyComposition.WinningPolicies";
31+
32+
/// <summary>
33+
/// The metadata key used to store the policies that blocked the composed result.
34+
/// </summary>
35+
private const string CompositionBlockingPoliciesKey = "PolicyComposition.BlockingPolicies";
36+
37+
/// <summary>
38+
/// Merges composition metadata with metadata produced by child policy decisions.
39+
/// </summary>
40+
/// <typeparam name="TState">The state type used by the evaluated policies.</typeparam>
41+
/// <param name="compositionName">The composed policy name.</param>
42+
/// <param name="mode">The composition mode used to produce the final decision.</param>
43+
/// <param name="evaluations">The ordered child policy evaluations.</param>
44+
/// <param name="winningPolicyNames">The policies that contributed to the composed result.</param>
45+
/// <param name="blockingPolicyNames">The policies that blocked the composed result.</param>
46+
/// <returns>The merged metadata dictionary for the composed policy decision.</returns>
47+
/// <exception cref="PolicyCompositionConflictException">
48+
/// Thrown when multiple child policies provide the same metadata key with different values.
49+
/// </exception>
50+
public static IReadOnlyDictionary<string, object> Merge<TState>(
51+
string compositionName,
52+
PolicyCompositionMode mode,
53+
IReadOnlyList<PolicyEvaluation<TState>> evaluations,
54+
IReadOnlyList<string> winningPolicyNames,
55+
IReadOnlyList<string> blockingPolicyNames)
56+
{
57+
var metadata = new Dictionary<string, object>(StringComparer.Ordinal)
58+
{
59+
[CompositionModeKey] = mode.ToString(),
60+
[CompositionPoliciesKey] = evaluations
61+
.Select((evaluation, index) => new
62+
{
63+
index,
64+
policy = evaluation.Policy.Name,
65+
severity = evaluation.Decision.Severity.ToString(),
66+
allowed = evaluation.Decision.IsAllowed
67+
})
68+
.ToArray(),
69+
[CompositionWinningPoliciesKey] = winningPolicyNames.ToArray(),
70+
[CompositionBlockingPoliciesKey] = blockingPolicyNames.ToArray()
71+
};
72+
73+
foreach (var evaluation in evaluations)
74+
{
75+
if (evaluation.Decision.Metadata is null)
76+
continue;
77+
78+
foreach (var (key, value) in evaluation.Decision.Metadata)
79+
{
80+
if (!metadata.TryGetValue(key, out var existing))
81+
{
82+
metadata[key] = value;
83+
continue;
84+
}
85+
86+
if (Equals(existing, value))
87+
continue;
88+
89+
throw new PolicyCompositionConflictException(
90+
compositionName,
91+
$"metadata:{key}",
92+
[.. evaluations
93+
.Where(candidate => candidate.Decision.Metadata is not null && candidate.Decision.Metadata.ContainsKey(key))
94+
.Select(candidate => candidate.Policy.Name)
95+
.Distinct(StringComparer.Ordinal)]);
96+
}
97+
}
98+
99+
return metadata;
100+
}
101+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using ModularityKit.Mutator.Abstractions.Effects;
2+
using ModularityKit.Mutator.Abstractions.Exceptions;
3+
4+
namespace ModularityKit.Mutator.Abstractions.Policies;
5+
6+
/// <summary>
7+
/// Merges modification payloads for composed policy decisions.
8+
/// </summary>
9+
/// <remarks>
10+
/// The merger folds child policy modification payloads into single composed
11+
/// modification dictionary. Regular modification keys must either be unique or
12+
/// contain equal values across contributing policies. Side effects are treated
13+
/// specially and are collected into a single side effects collection.
14+
/// </remarks>
15+
internal static class PolicyDecisionModificationMerger
16+
{
17+
/// <summary>
18+
/// The modification key used for single side effect.
19+
/// </summary>
20+
private const string SideEffectKey = "SideEffect";
21+
22+
/// <summary>
23+
/// The modification key used for collection of side effects.
24+
/// </summary>
25+
private const string SideEffectsKey = "SideEffects";
26+
27+
/// <summary>
28+
/// Merges modification payloads produced by child policy decisions.
29+
/// </summary>
30+
/// <typeparam name="TState">The state type used by the evaluated policies.</typeparam>
31+
/// <param name="compositionName">The composed policy name.</param>
32+
/// <param name="evaluations">The child policy evaluations that contribute modifications.</param>
33+
/// <returns>
34+
/// The merged modification dictionary, or <c>null</c> when no modifications were produced.
35+
/// </returns>
36+
/// <exception cref="PolicyCompositionConflictException">
37+
/// Thrown when multiple policies provide the same modification key with different values,
38+
/// or when the side effects payload has an unsupported shape.
39+
/// </exception>
40+
public static IReadOnlyDictionary<string, object>? Merge<TState>(
41+
string compositionName,
42+
IReadOnlyList<PolicyEvaluation<TState>> evaluations)
43+
{
44+
var merged = new Dictionary<string, object>(StringComparer.Ordinal);
45+
var sources = new Dictionary<string, string>(StringComparer.Ordinal);
46+
var sideEffects = new List<SideEffect>();
47+
48+
foreach (var evaluation in evaluations)
49+
{
50+
if (evaluation.Decision.Modifications is null)
51+
continue;
52+
53+
foreach (var (key, value) in evaluation.Decision.Modifications)
54+
{
55+
if (key == SideEffectKey)
56+
{
57+
if (value is SideEffect sideEffect)
58+
{
59+
sideEffects.Add(sideEffect);
60+
}
61+
62+
continue;
63+
}
64+
65+
if (key == SideEffectsKey)
66+
{
67+
if (value is IEnumerable<SideEffect> effects)
68+
{
69+
sideEffects.AddRange(effects);
70+
continue;
71+
}
72+
73+
throw new PolicyCompositionConflictException(
74+
compositionName,
75+
SideEffectsKey,
76+
[evaluation.Policy.Name]);
77+
}
78+
79+
if (!merged.TryGetValue(key, out var existing))
80+
{
81+
merged[key] = value;
82+
sources[key] = evaluation.Policy.Name;
83+
continue;
84+
}
85+
86+
if (Equals(existing, value))
87+
continue;
88+
89+
throw new PolicyCompositionConflictException(
90+
compositionName,
91+
key,
92+
[sources[key], evaluation.Policy.Name]);
93+
}
94+
}
95+
96+
if (sideEffects.Count > 0)
97+
merged[SideEffectsKey] = sideEffects;
98+
99+
return merged.Count == 0 ? null : merged;
100+
}
101+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using ModularityKit.Mutator.Abstractions.Engine;
2+
3+
namespace ModularityKit.Mutator.Abstractions.Policies;
4+
5+
/// <summary>
6+
/// Captures one evaluated policy and its decision.
7+
/// </summary>
8+
/// <typeparam name="TState">The state type used by the evaluated policy.</typeparam>
9+
/// <param name="Policy">The policy that was evaluated.</param>
10+
/// <param name="Decision">The decision produced by the policy.</param>
11+
internal readonly record struct PolicyEvaluation<TState>(
12+
IMutationPolicy<TState> Policy,
13+
PolicyDecision Decision);

0 commit comments

Comments
 (0)