|
| 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 | +} |
0 commit comments