From 5035482b09664d2e4fde2805333a3c386be81f1b Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:28:21 +0200 Subject: [PATCH 01/15] feat(governance): expand request query model --- .../Queries/Model/MutationRequestQuery.cs | 163 ---------------- .../Model/MutationRequestQueryEvaluator.cs | 157 ---------------- .../Filters/MutationRequestActorFilter.cs | 17 ++ .../Filters/MutationRequestIntentFilter.cs | 39 ++++ .../Filters/MutationRequestLifecycleFilter.cs | 26 +++ .../Filters/MutationRequestMetadataFilter.cs | 12 ++ .../Filters/MutationRequestScopeFilter.cs | 27 +++ .../Filters/MutationRequestTagMatchMode.cs | 17 ++ .../Filters/MutationRequestTimeRangeFilter.cs | 27 +++ .../Model/Requests/MutationRequestQueries.cs | 60 ++++++ .../Model/Requests/MutationRequestQuery.cs | 39 ++++ .../Requests/MutationRequestQueryEvaluator.cs | 177 ++++++++++++++++++ 12 files changed, 441 insertions(+), 320 deletions(-) delete mode 100644 src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs delete mode 100644 src/Governance/Abstractions/Queries/Model/MutationRequestQueryEvaluator.cs create mode 100644 src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestActorFilter.cs create mode 100644 src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestIntentFilter.cs create mode 100644 src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestLifecycleFilter.cs create mode 100644 src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestMetadataFilter.cs create mode 100644 src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestScopeFilter.cs create mode 100644 src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTagMatchMode.cs create mode 100644 src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTimeRangeFilter.cs create mode 100644 src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueries.cs create mode 100644 src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs create mode 100644 src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueryEvaluator.cs diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs b/src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs deleted file mode 100644 index 1bc0f49..0000000 --- a/src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs +++ /dev/null @@ -1,163 +0,0 @@ -using ModularityKit.Mutator.Abstractions.Intent; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; - -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; - -/// -/// Defines storage-agnostic filters for governed mutation request queries. -/// -public sealed record MutationRequestQuery -{ - /// - /// Specific request identifiers to include. - /// - public IReadOnlySet RequestIds { get; init; } = new HashSet(); - - /// - /// State identifiers to include. - /// - public IReadOnlySet StateIds { get; init; } = new HashSet(); - - /// - /// State types to include. - /// - public IReadOnlySet StateTypes { get; init; } = new HashSet(); - - /// - /// Mutation types to include. - /// - public IReadOnlySet MutationTypes { get; init; } = new HashSet(); - - /// - /// Actor identifiers to include. - /// - public IReadOnlySet ActorIds { get; init; } = new HashSet(); - - /// - /// Actor names to include. - /// - public IReadOnlySet ActorNames { get; init; } = new HashSet(); - - /// - /// Mutation categories to include. - /// - public IReadOnlySet Categories { get; init; } = new HashSet(); - - /// - /// Request statuses to include. - /// - public IReadOnlySet Statuses { get; init; } = new HashSet(); - - /// - /// Pending reasons to include. - /// - public IReadOnlySet PendingReasons { get; init; } = new HashSet(); - - /// - /// Tags to include from the request intent. - /// - public IReadOnlySet Tags { get; init; } = new HashSet(); - - /// - /// Tag matching strategy. - /// - public MutationRequestTagMatchMode TagMatchMode { get; init; } = MutationRequestTagMatchMode.Any; - - /// - /// Exact metadata key/value pairs to match against request metadata. - /// - public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); - - /// - /// Minimum estimated blast radius scope to include. - /// - public BlastRadiusScope? MinimumBlastRadiusScope { get; init; } - - /// - /// Maximum estimated blast radius scope to include. - /// - public BlastRadiusScope? MaximumBlastRadiusScope { get; init; } - - /// - /// Inclusive lower bound for request creation time. - /// - public DateTimeOffset? CreatedFrom { get; init; } - - /// - /// Inclusive upper bound for request creation time. - /// - public DateTimeOffset? CreatedTo { get; init; } - - /// - /// Inclusive lower bound for request update time. - /// - public DateTimeOffset? UpdatedFrom { get; init; } - - /// - /// Inclusive upper bound for request update time. - /// - public DateTimeOffset? UpdatedTo { get; init; } - - /// - /// Decision categories that must appear in the request history. - /// - public IReadOnlySet DecisionCategories { get; init; } - = new HashSet(); - - /// - /// Creates a query that targets pending requests. - /// - public static MutationRequestQuery Pending() - => new() - { - Statuses = new HashSet { MutationRequestStatus.Pending } - }; - - /// - /// Creates a query that targets the pending approval queue. - /// - public static MutationRequestQuery PendingApprovalQueue() - => new() - { - Statuses = new HashSet { MutationRequestStatus.Pending }, - PendingReasons = new HashSet { PendingMutationReason.Approval }, - DecisionCategories = new HashSet - { - MutationRequestDecisionCategory.Approval - } - }; - - /// - /// Creates a query that targets approval-driven requests that recently moved through approval. - /// - public static MutationRequestQuery RecentApprovals() - => new() - { - Statuses = new HashSet - { - MutationRequestStatus.Approved, - MutationRequestStatus.Executed - }, - DecisionCategories = new HashSet - { - MutationRequestDecisionCategory.Approval - } - }; -} - -/// -/// Controls how tag filters are evaluated. -/// -public enum MutationRequestTagMatchMode -{ - /// - /// Match requests that contain at least one of the requested tags. - /// - Any = 0, - - /// - /// Match requests that contain all requested tags. - /// - All = 1 -} diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/MutationRequestQueryEvaluator.cs deleted file mode 100644 index 379dfb4..0000000 --- a/src/Governance/Abstractions/Queries/Model/MutationRequestQueryEvaluator.cs +++ /dev/null @@ -1,157 +0,0 @@ -using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; - -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; - -/// -/// Evaluates query criteria against governed mutation requests. -/// -public static class MutationRequestQueryEvaluator -{ - public static bool Matches(MutationRequest request, MutationRequestQuery query) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(query); - - return MatchesRequestId(request, query) && - MatchesStateId(request, query) && - MatchesStateType(request, query) && - MatchesMutationType(request, query) && - MatchesActorId(request, query) && - MatchesActorName(request, query) && - MatchesCategory(request, query) && - MatchesStatus(request, query) && - MatchesPendingReason(request, query) && - MatchesTags(request, query) && - MatchesMetadata(request, query) && - MatchesBlastRadius(request, query) && - MatchesCreatedAt(request, query) && - MatchesUpdatedAt(request, query) && - MatchesDecisionCategories(request, query); - } - - public static bool HasApprovalActivity(MutationRequest request) - { - ArgumentNullException.ThrowIfNull(request); - - return request.ApprovalRequirements.Count > 0 || - request.Decisions.Any(decision => decision.Type.Category == MutationRequestDecisionCategory.Approval); - } - - public static DateTimeOffset GetRecentApprovalTimestamp(MutationRequest request) - { - ArgumentNullException.ThrowIfNull(request); - - var approvalDecision = request.Decisions - .Where(decision => decision.Type.Category == MutationRequestDecisionCategory.Approval && - decision.Type.Code != MutationRequestApprovalDecisionType.Requested.ToString()) - .OrderByDescending(decision => decision.Timestamp) - .FirstOrDefault(); - - return approvalDecision?.Timestamp ?? request.UpdatedAt; - } - - private static bool MatchesMetadata(MutationRequest request, MutationRequestQuery query) - => query.Metadata.Count == 0 || MatchesMetadata(request.Metadata, query.Metadata); - - private static bool MatchesRequestId(MutationRequest request, MutationRequestQuery query) - => query.RequestIds.Count == 0 || query.RequestIds.Contains(request.RequestId); - - private static bool MatchesStateId(MutationRequest request, MutationRequestQuery query) - => query.StateIds.Count == 0 || query.StateIds.Contains(request.StateId); - - private static bool MatchesStateType(MutationRequest request, MutationRequestQuery query) - => query.StateTypes.Count == 0 || query.StateTypes.Contains(request.StateType); - - private static bool MatchesMutationType(MutationRequest request, MutationRequestQuery query) - => query.MutationTypes.Count == 0 || query.MutationTypes.Contains(request.MutationType); - - private static bool MatchesActorId(MutationRequest request, MutationRequestQuery query) - => query.ActorIds.Count == 0 || - (request.Context.ActorId is not null && query.ActorIds.Contains(request.Context.ActorId)); - - private static bool MatchesActorName(MutationRequest request, MutationRequestQuery query) - => query.ActorNames.Count == 0 || - (request.Context.ActorName is not null && query.ActorNames.Contains(request.Context.ActorName)); - - private static bool MatchesCategory(MutationRequest request, MutationRequestQuery query) - => query.Categories.Count == 0 || query.Categories.Contains(request.Intent.Category); - - private static bool MatchesStatus(MutationRequest request, MutationRequestQuery query) - => query.Statuses.Count == 0 || query.Statuses.Contains(request.Status); - - private static bool MatchesPendingReason(MutationRequest request, MutationRequestQuery query) - => query.PendingReasons.Count == 0 || - (request.PendingReason is not null && query.PendingReasons.Contains(request.PendingReason.Value)); - - private static bool MatchesTags(MutationRequest request, MutationRequestQuery query) - { - if (query.Tags.Count == 0) - return true; - - var requestTags = request.Intent.Tags; - return query.TagMatchMode == MutationRequestTagMatchMode.All - ? query.Tags.All(requestTags.Contains) - : query.Tags.Any(requestTags.Contains); - } - - private static bool MatchesBlastRadius(MutationRequest request, MutationRequestQuery query) - { - if (!query.MinimumBlastRadiusScope.HasValue && !query.MaximumBlastRadiusScope.HasValue) - return true; - - var scope = request.Intent.EstimatedBlastRadius?.Scope; - if (scope is null) - return false; - - if (query.MinimumBlastRadiusScope.HasValue && scope.Value < query.MinimumBlastRadiusScope.Value) - return false; - - if (query.MaximumBlastRadiusScope.HasValue && scope.Value > query.MaximumBlastRadiusScope.Value) - return false; - - return true; - } - - private static bool MatchesCreatedAt(MutationRequest request, MutationRequestQuery query) - { - if (query.CreatedFrom.HasValue && request.CreatedAt < query.CreatedFrom.Value) - return false; - - if (query.CreatedTo.HasValue && request.CreatedAt > query.CreatedTo.Value) - return false; - - return true; - } - - private static bool MatchesUpdatedAt(MutationRequest request, MutationRequestQuery query) - { - if (query.UpdatedFrom.HasValue && request.UpdatedAt < query.UpdatedFrom.Value) - return false; - - if (query.UpdatedTo.HasValue && request.UpdatedAt > query.UpdatedTo.Value) - return false; - - return true; - } - - private static bool MatchesDecisionCategories(MutationRequest request, MutationRequestQuery query) - => query.DecisionCategories.Count == 0 || - request.Decisions.Any(decision => query.DecisionCategories.Contains(decision.Type.Category)); - - private static bool MatchesMetadata( - IReadOnlyDictionary requestMetadata, - IReadOnlyDictionary queryMetadata) - { - foreach (var pair in queryMetadata) - { - if (!requestMetadata.TryGetValue(pair.Key, out var value)) - return false; - - if (!Equals(value, pair.Value)) - return false; - } - - return true; - } -} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestActorFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestActorFilter.cs new file mode 100644 index 0000000..af2981d --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestActorFilter.cs @@ -0,0 +1,17 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Groups actor oriented request query filters. +/// +public sealed record MutationRequestActorFilter +{ + /// + /// Actor identifiers to include. + /// + public IReadOnlySet ActorIds { get; init; } = new HashSet(); + + /// + /// Actor names to include. + /// + public IReadOnlySet ActorNames { get; init; } = new HashSet(); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestIntentFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestIntentFilter.cs new file mode 100644 index 0000000..f989ffd --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestIntentFilter.cs @@ -0,0 +1,39 @@ +using ModularityKit.Mutator.Abstractions.Intent; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Groups intent oriented request query filters. +/// +public sealed record MutationRequestIntentFilter +{ + /// + /// Mutation categories to include. + /// + public IReadOnlySet Categories { get; init; } = new HashSet(); + + /// + /// Tags to include from the request intent. + /// + public IReadOnlySet Tags { get; init; } = new HashSet(); + + /// + /// Tag matching strategy. + /// + public MutationRequestTagMatchMode TagMatchMode { get; init; } = MutationRequestTagMatchMode.Any; + + /// + /// Exact metadata key/value pairs to match against request intent metadata. + /// + public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); + + /// + /// Minimum estimated blast radius scope to include. + /// + public BlastRadiusScope? MinimumBlastRadiusScope { get; init; } + + /// + /// Maximum estimated blast radius scope to include. + /// + public BlastRadiusScope? MaximumBlastRadiusScope { get; init; } +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestLifecycleFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestLifecycleFilter.cs new file mode 100644 index 0000000..93c29d0 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestLifecycleFilter.cs @@ -0,0 +1,26 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Groups lifecycle oriented request query filters. +/// +public sealed record MutationRequestLifecycleFilter +{ + /// + /// Request statuses to include. + /// + public IReadOnlySet Statuses { get; init; } = new HashSet(); + + /// + /// Pending reasons to include. + /// + public IReadOnlySet PendingReasons { get; init; } = new HashSet(); + + /// + /// Decision categories that must appear in the request history. + /// + public IReadOnlySet DecisionCategories { get; init; } + = new HashSet(); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestMetadataFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestMetadataFilter.cs new file mode 100644 index 0000000..f265a58 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestMetadataFilter.cs @@ -0,0 +1,12 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Groups request level governance metadata filters. +/// +public sealed record MutationRequestMetadataFilter +{ + /// + /// Exact metadata key/value pairs to match against request level governance metadata. + /// + public IReadOnlyDictionary Values { get; init; } = new Dictionary(); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestScopeFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestScopeFilter.cs new file mode 100644 index 0000000..117cc74 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestScopeFilter.cs @@ -0,0 +1,27 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Groups identifier oriented request query filters. +/// +public sealed record MutationRequestScopeFilter +{ + /// + /// Specific request identifiers to include. + /// + public IReadOnlySet RequestIds { get; init; } = new HashSet(); + + /// + /// State identifiers to include. + /// + public IReadOnlySet StateIds { get; init; } = new HashSet(); + + /// + /// State types to include. + /// + public IReadOnlySet StateTypes { get; init; } = new HashSet(); + + /// + /// Mutation types to include. + /// + public IReadOnlySet MutationTypes { get; init; } = new HashSet(); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTagMatchMode.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTagMatchMode.cs new file mode 100644 index 0000000..89a07af --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTagMatchMode.cs @@ -0,0 +1,17 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Controls how tag filters are evaluated. +/// +public enum MutationRequestTagMatchMode +{ + /// + /// Match requests that contain at least one of the requested tags. + /// + Any = 0, + + /// + /// Match requests that contain all requested tags. + /// + All = 1 +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTimeRangeFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTimeRangeFilter.cs new file mode 100644 index 0000000..11c913d --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTimeRangeFilter.cs @@ -0,0 +1,27 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Groups time window request query filters. +/// +public sealed record MutationRequestTimeRangeFilter +{ + /// + /// Inclusive lower bound for request creation time. + /// + public DateTimeOffset? CreatedFrom { get; init; } + + /// + /// Inclusive upper bound for request creation time. + /// + public DateTimeOffset? CreatedTo { get; init; } + + /// + /// Inclusive lower bound for request update time. + /// + public DateTimeOffset? UpdatedFrom { get; init; } + + /// + /// Inclusive upper bound for request update time. + /// + public DateTimeOffset? UpdatedTo { get; init; } +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueries.cs b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueries.cs new file mode 100644 index 0000000..4bdbff7 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueries.cs @@ -0,0 +1,60 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; + +/// +/// Common request query presets for governance workflows. +/// +public static class MutationRequestQueries +{ + /// + /// Creates query that targets pending requests. + /// + public static MutationRequestQuery Pending() + => new() + { + Lifecycle = new MutationRequestLifecycleFilter + { + Statuses = new HashSet { MutationRequestStatus.Pending } + } + }; + + /// + /// Creates query that targets the pending approval queue. + /// + public static MutationRequestQuery PendingApprovalQueue() + => new() + { + Lifecycle = new MutationRequestLifecycleFilter + { + Statuses = new HashSet { MutationRequestStatus.Pending }, + PendingReasons = new HashSet { PendingMutationReason.Approval }, + DecisionCategories = new HashSet + { + MutationRequestDecisionCategory.Approval + } + } + }; + + /// + /// Creates query that targets approval driven requests that recently moved through approval. + /// + public static MutationRequestQuery RecentApprovals() + => new() + { + Lifecycle = new MutationRequestLifecycleFilter + { + Statuses = new HashSet + { + MutationRequestStatus.Approved, + MutationRequestStatus.Executed + }, + DecisionCategories = new HashSet + { + MutationRequestDecisionCategory.Approval + } + } + }; +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs new file mode 100644 index 0000000..bf91f98 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs @@ -0,0 +1,39 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; + +/// +/// Defines storage agnostic filters for governed mutation request queries. +/// +public sealed record MutationRequestQuery +{ + /// + /// Identifier oriented filters such as request, state, and mutation identity. + /// + public MutationRequestScopeFilter Scope { get; init; } = new(); + + /// + /// Actor oriented filters derived from the request context. + /// + public MutationRequestActorFilter Actor { get; init; } = new(); + + /// + /// Intent oriented filters such as category, tags, metadata, and blast radius. + /// + public MutationRequestIntentFilter Intent { get; init; } = new(); + + /// + /// Request-level governance metadata filters. + /// + public MutationRequestMetadataFilter Metadata { get; init; } = new(); + + /// + /// Lifecycle and decision-history filters. + /// + public MutationRequestLifecycleFilter Lifecycle { get; init; } = new(); + + /// + /// Time-window filters for request creation and update activity. + /// + public MutationRequestTimeRangeFilter TimeRange { get; init; } = new(); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueryEvaluator.cs new file mode 100644 index 0000000..177f51d --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueryEvaluator.cs @@ -0,0 +1,177 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; + +/// +/// Evaluates query criteria against governed mutation requests. +/// +public static class MutationRequestQueryEvaluator +{ + public static bool Matches(MutationRequest request, MutationRequestQuery query) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(query); + + return MatchesScope(request, query.Scope) && + MatchesActor(request, query.Actor) && + MatchesIntent(request, query.Intent) && + MatchesMetadata(request, query.Metadata) && + MatchesLifecycle(request, query.Lifecycle) && + MatchesTimeRange(request, query.TimeRange); + } + + public static bool HasApprovalActivity(MutationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + return request.ApprovalRequirements.Count > 0 || + request.Decisions.Any(decision => decision.Type.Category == MutationRequestDecisionCategory.Approval); + } + + public static DateTimeOffset GetRecentApprovalTimestamp(MutationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var approvalDecision = request.Decisions + .Where(decision => decision.Type.Category == MutationRequestDecisionCategory.Approval && + decision.Type.Code != MutationRequestApprovalDecisionType.Requested.ToString()) + .OrderByDescending(decision => decision.Timestamp) + .FirstOrDefault(); + + return approvalDecision?.Timestamp ?? request.UpdatedAt; + } + + private static bool MatchesScope(MutationRequest request, MutationRequestScopeFilter filter) + => MatchesRequestId(request, filter) && + MatchesStateId(request, filter) && + MatchesStateType(request, filter) && + MatchesMutationType(request, filter); + + private static bool MatchesActor(MutationRequest request, MutationRequestActorFilter filter) + => MatchesActorId(request, filter) && + MatchesActorName(request, filter); + + private static bool MatchesIntent(MutationRequest request, MutationRequestIntentFilter filter) + => MatchesCategory(request, filter) && + MatchesTags(request, filter) && + MatchesIntentMetadata(request, filter) && + MatchesBlastRadius(request, filter); + + private static bool MatchesMetadata(MutationRequest request, MutationRequestMetadataFilter filter) + => filter.Values.Count == 0 || MatchesMetadata(request.Metadata, filter.Values); + + private static bool MatchesLifecycle(MutationRequest request, MutationRequestLifecycleFilter filter) + => MatchesStatus(request, filter) && + MatchesPendingReason(request, filter) && + MatchesDecisionCategories(request, filter); + + private static bool MatchesTimeRange(MutationRequest request, MutationRequestTimeRangeFilter filter) + => MatchesCreatedAt(request, filter) && + MatchesUpdatedAt(request, filter); + + private static bool MatchesIntentMetadata(MutationRequest request, MutationRequestIntentFilter filter) + => filter.Metadata.Count == 0 || MatchesMetadata(request.Intent.Metadata, filter.Metadata); + + private static bool MatchesRequestId(MutationRequest request, MutationRequestScopeFilter filter) + => filter.RequestIds.Count == 0 || filter.RequestIds.Contains(request.RequestId); + + private static bool MatchesStateId(MutationRequest request, MutationRequestScopeFilter filter) + => filter.StateIds.Count == 0 || filter.StateIds.Contains(request.StateId); + + private static bool MatchesStateType(MutationRequest request, MutationRequestScopeFilter filter) + => filter.StateTypes.Count == 0 || filter.StateTypes.Contains(request.StateType); + + private static bool MatchesMutationType(MutationRequest request, MutationRequestScopeFilter filter) + => filter.MutationTypes.Count == 0 || filter.MutationTypes.Contains(request.MutationType); + + private static bool MatchesActorId(MutationRequest request, MutationRequestActorFilter filter) + => filter.ActorIds.Count == 0 || + (request.Context.ActorId is not null && filter.ActorIds.Contains(request.Context.ActorId)); + + private static bool MatchesActorName(MutationRequest request, MutationRequestActorFilter filter) + => filter.ActorNames.Count == 0 || + (request.Context.ActorName is not null && filter.ActorNames.Contains(request.Context.ActorName)); + + private static bool MatchesCategory(MutationRequest request, MutationRequestIntentFilter filter) + => filter.Categories.Count == 0 || filter.Categories.Contains(request.Intent.Category); + + private static bool MatchesStatus(MutationRequest request, MutationRequestLifecycleFilter filter) + => filter.Statuses.Count == 0 || filter.Statuses.Contains(request.Status); + + private static bool MatchesPendingReason(MutationRequest request, MutationRequestLifecycleFilter filter) + => filter.PendingReasons.Count == 0 || + (request.PendingReason is not null && filter.PendingReasons.Contains(request.PendingReason.Value)); + + private static bool MatchesTags(MutationRequest request, MutationRequestIntentFilter filter) + { + if (filter.Tags.Count == 0) + return true; + + var requestTags = request.Intent.Tags; + return filter.TagMatchMode == MutationRequestTagMatchMode.All + ? filter.Tags.All(requestTags.Contains) + : filter.Tags.Any(requestTags.Contains); + } + + private static bool MatchesBlastRadius(MutationRequest request, MutationRequestIntentFilter filter) + { + if (!filter.MinimumBlastRadiusScope.HasValue && !filter.MaximumBlastRadiusScope.HasValue) + return true; + + var scope = request.Intent.EstimatedBlastRadius?.Scope; + if (scope is null) + return false; + + if (filter.MinimumBlastRadiusScope.HasValue && scope.Value < filter.MinimumBlastRadiusScope.Value) + return false; + + if (filter.MaximumBlastRadiusScope.HasValue && scope.Value > filter.MaximumBlastRadiusScope.Value) + return false; + + return true; + } + + private static bool MatchesCreatedAt(MutationRequest request, MutationRequestTimeRangeFilter filter) + { + if (filter.CreatedFrom.HasValue && request.CreatedAt < filter.CreatedFrom.Value) + return false; + + if (filter.CreatedTo.HasValue && request.CreatedAt > filter.CreatedTo.Value) + return false; + + return true; + } + + private static bool MatchesUpdatedAt(MutationRequest request, MutationRequestTimeRangeFilter filter) + { + if (filter.UpdatedFrom.HasValue && request.UpdatedAt < filter.UpdatedFrom.Value) + return false; + + if (filter.UpdatedTo.HasValue && request.UpdatedAt > filter.UpdatedTo.Value) + return false; + + return true; + } + + private static bool MatchesDecisionCategories(MutationRequest request, MutationRequestLifecycleFilter filter) + => filter.DecisionCategories.Count == 0 || + request.Decisions.Any(decision => filter.DecisionCategories.Contains(decision.Type.Category)); + + private static bool MatchesMetadata( + IReadOnlyDictionary requestMetadata, + IReadOnlyDictionary queryMetadata) + { + foreach (var pair in queryMetadata) + { + if (!requestMetadata.TryGetValue(pair.Key, out var value)) + return false; + + if (!Equals(value, pair.Value)) + return false; + } + + return true; + } +} From f4cead98bfdf0a40c5e7bb6023ae5c4e1539d8ea Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:30:00 +0200 Subject: [PATCH 02/15] refactor(governance): align approval and decision query namespaces --- .../Contracts/IMutationRequestQueryStore.cs | 4 +- .../{ => Approvals}/MutationApprovalQuery.cs | 4 +- .../MutationApprovalQueryEvaluator.cs | 3 +- .../{ => Approvals}/MutationApprovalView.cs | 2 +- .../MutationRequestDecisionQuery.cs | 4 +- .../MutationRequestDecisionQueryEvaluator.cs | 3 +- .../MutationRequestDecisionView.cs | 2 +- .../Decisions/MutationRequestDecision.cs | 42 +++++++++++++++++++ .../Factory/MutationRequestFactory.cs | 28 ++++++------- 9 files changed, 71 insertions(+), 21 deletions(-) rename src/Governance/Abstractions/Queries/Model/{ => Approvals}/MutationApprovalQuery.cs (96%) rename src/Governance/Abstractions/Queries/Model/{ => Approvals}/MutationApprovalQueryEvaluator.cs (96%) rename src/Governance/Abstractions/Queries/Model/{ => Approvals}/MutationApprovalView.cs (97%) rename src/Governance/Abstractions/Queries/Model/{ => Decisions}/MutationRequestDecisionQuery.cs (96%) rename src/Governance/Abstractions/Queries/Model/{ => Decisions}/MutationRequestDecisionQueryEvaluator.cs (96%) rename src/Governance/Abstractions/Queries/Model/{ => Decisions}/MutationRequestDecisionView.cs (97%) diff --git a/src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs b/src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs index f8c327d..810bfdb 100644 --- a/src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs +++ b/src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs @@ -1,5 +1,7 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; diff --git a/src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQuery.cs similarity index 96% rename from src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs rename to src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQuery.cs index 9b7a981..7928d2a 100644 --- a/src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs +++ b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQuery.cs @@ -1,7 +1,9 @@ using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; /// /// Defines storage-agnostic filters for approval-oriented governance queries. diff --git a/src/Governance/Abstractions/Queries/Model/MutationApprovalQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQueryEvaluator.cs similarity index 96% rename from src/Governance/Abstractions/Queries/Model/MutationApprovalQueryEvaluator.cs rename to src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQueryEvaluator.cs index af60980..72c7040 100644 --- a/src/Governance/Abstractions/Queries/Model/MutationApprovalQueryEvaluator.cs +++ b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQueryEvaluator.cs @@ -1,7 +1,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; /// /// Evaluates approval oriented query criteria against governed mutation requests. diff --git a/src/Governance/Abstractions/Queries/Model/MutationApprovalView.cs b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalView.cs similarity index 97% rename from src/Governance/Abstractions/Queries/Model/MutationApprovalView.cs rename to src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalView.cs index 90c32bd..9ecfc65 100644 --- a/src/Governance/Abstractions/Queries/Model/MutationApprovalView.cs +++ b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalView.cs @@ -1,7 +1,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; /// /// Represents one approval oriented projection from governed mutation request. diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQuery.cs similarity index 96% rename from src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs rename to src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQuery.cs index 14bcd7d..ed1734d 100644 --- a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs +++ b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQuery.cs @@ -1,6 +1,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; /// /// Defines storage agnostic filters for decision oriented governance queries. diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQueryEvaluator.cs similarity index 96% rename from src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQueryEvaluator.cs rename to src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQueryEvaluator.cs index 1a6313a..6bcb55a 100644 --- a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQueryEvaluator.cs +++ b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQueryEvaluator.cs @@ -1,7 +1,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; /// /// Evaluates decision oriented query criteria against governed mutation requests. diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionView.cs b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionView.cs similarity index 97% rename from src/Governance/Abstractions/Queries/Model/MutationRequestDecisionView.cs rename to src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionView.cs index 6ef3212..b96ab33 100644 --- a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionView.cs +++ b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionView.cs @@ -1,7 +1,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; /// /// Represents one decision-oriented projection from a governed mutation request. diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs index 852272c..fe238e3 100644 --- a/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs @@ -32,6 +32,48 @@ public sealed record MutationRequestDecision /// public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); + /// + /// Creates a lifecycle decision entry. + /// + public static MutationRequestDecision Lifecycle( + MutationRequestLifecycleDecisionType type, + MutationContext context, + string? reason = null, + IReadOnlyDictionary? metadata = null) + => Create( + MutationRequestDecisionType.Lifecycle(type), + context, + reason, + metadata); + + /// + /// Creates an approval decision entry. + /// + public static MutationRequestDecision Approval( + MutationRequestApprovalDecisionType type, + MutationContext context, + string? reason = null, + IReadOnlyDictionary? metadata = null) + => Create( + MutationRequestDecisionType.Approval(type), + context, + reason, + metadata); + + /// + /// Creates a version-resolution decision entry. + /// + public static MutationRequestDecision VersionResolution( + MutationRequestVersionResolutionDecisionType type, + MutationContext context, + string? reason = null, + IReadOnlyDictionary? metadata = null) + => Create( + MutationRequestDecisionType.VersionResolution(type), + context, + reason, + metadata); + /// /// Creates a new request decision entry. /// diff --git a/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs b/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs index 15a831a..9b692ff 100644 --- a/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs +++ b/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs @@ -69,12 +69,12 @@ public static MutationRequest Pending( Metadata = metadata ?? new Dictionary(), Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, context, reason: context.Reason), - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Pending, context, reason: $"Request entered pending lifecycle for reason '{pendingReason}'.") ] @@ -140,16 +140,16 @@ public static MutationRequest PendingApproval( Metadata = metadata ?? new Dictionary(), Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, context, reason: context.Reason), - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Pending, context, reason: "Request entered pending approval."), - MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), + MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Requested, context, reason: $"Request requires {approvalRequirements.Count} approval action(s).", metadata: new Dictionary @@ -203,12 +203,12 @@ public static MutationRequest Approved( Metadata = metadata ?? new Dictionary(), Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, context, reason: context.Reason), - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, context, reason: "Approved at submission time") ] From 3742e99b7483d5867fd4903af35d5d6b24687d9e Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:33:24 +0200 Subject: [PATCH 03/15] refactor(governance-runtime): use decision category helpers --- .../MutationRequestApprovalDecisionExecutor.cs | 12 ++++++------ .../MutationRequestApprovalExpirationExecutor.cs | 4 ++-- .../Outcome/GovernedExecutionDecisionFactory.cs | 8 ++++---- .../MutationRequestVersionResolutionFactory.cs | 16 ++++++++-------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs index 8c5db0c..6b1d1ca 100644 --- a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs +++ b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs @@ -106,8 +106,8 @@ private static List BuildDecisionHistory( string.Equals(requirement.ApprovalGroupId, resolvedRequirement.ApprovalGroupId, StringComparison.Ordinal) && requirement.Status == MutationApprovalRequirementStatus.Satisfied)) { - decisions.Add(MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.QuorumSatisfied), + decisions.Add(MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.QuorumSatisfied, decisionContext, reason: $"Approval quorum satisfied for group '{resolvedRequirement.ApprovalGroupId}'.", metadata: new Dictionary @@ -122,15 +122,15 @@ private static List BuildDecisionHistory( if (finalizeApprovedRequest && isFullyApproved) { - decisions.Add(MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + decisions.Add(MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, decisionContext, reason: "All approval requirements were fulfilled.")); } else if (!finalizeApprovedRequest) { - decisions.Add(MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected), + decisions.Add(MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Rejected, decisionContext, reason: reason ?? rejection?.Message ?? decisionContext.Reason ?? "Request was rejected during approval workflow.")); } diff --git a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs index bd54bf7..104cd53 100644 --- a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs +++ b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs @@ -60,8 +60,8 @@ requirement.ExpiresAt is not null && ? "Approval requirement expired." : $"Approval requirement expired at '{requirement.ExpiresAt:O}'."))); - decisions.Add(MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected), + decisions.Add(MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Rejected, decisionContext, reason: "Request was rejected because one or more approval requirements expired.")); diff --git a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs index 66b1eca..e360e24 100644 --- a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs +++ b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs @@ -14,8 +14,8 @@ public static MutationRequestDecision CreateRejectedDecision( string reason, IReadOnlyDictionary metadata) { - return MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected), + return MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Rejected, governanceContext, reason, metadata); @@ -26,8 +26,8 @@ public static MutationRequestDecision CreateExecutedDecision( string resultingStateVersion, MutationResult mutationResult) { - return MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), + return MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Executed, governanceContext, "Governed request executed successfully.", new Dictionary diff --git a/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionFactory.cs b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionFactory.cs index 829aa56..72f6b2d 100644 --- a/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionFactory.cs +++ b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionFactory.cs @@ -20,8 +20,8 @@ public static MutationRequestVersionResolution BuildValidated( MutationRequestVersionEvaluation evaluation, MutationContext resolutionContext) { - var validatedDecision = MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.Validated), + var validatedDecision = MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.Validated, resolutionContext, reason: MutationRequestVersionResolutionState.BuildValidatedReason( evaluation.ExpectedStateVersion, @@ -48,8 +48,8 @@ public static MutationRequestVersionResolution BuildRejectedAsStale( MutationRequestVersionEvaluation evaluation, MutationContext resolutionContext) { - var decision = MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RejectedAsStale), + var decision = MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.RejectedAsStale, resolutionContext, reason: MutationRequestVersionResolutionState.BuildStaleReason( evaluation.ExpectedStateVersion!, @@ -81,8 +81,8 @@ public static MutationRequestVersionResolution BuildRenewedApprovalRequired( MutationRequestVersionEvaluation evaluation, MutationContext resolutionContext) { - var decision = MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RenewedApprovalRequired), + var decision = MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.RenewedApprovalRequired, resolutionContext, reason: MutationRequestVersionResolutionState.BuildStaleReason( evaluation.ExpectedStateVersion!, @@ -114,8 +114,8 @@ public static MutationRequestVersionResolution BuildRevalidationRequired( MutationRequestVersionEvaluation evaluation, MutationContext resolutionContext) { - var decision = MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RevalidationRequired), + var decision = MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.RevalidationRequired, resolutionContext, reason: MutationRequestVersionResolutionState.BuildStaleReason( evaluation.ExpectedStateVersion!, From 19a144625e4f2d416480d9982b7f337b9a41da6d Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:33:43 +0200 Subject: [PATCH 04/15] feat(governance-runtime): propagate request metadata into governed mutations --- .../Execution/Mutation/GovernedMutation.cs | 58 ++++++++++++++----- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs b/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs index 437a436..5a4ccb8 100644 --- a/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs +++ b/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs @@ -2,19 +2,26 @@ using ModularityKit.Mutator.Abstractions.Engine; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Runtime.Execution.Mutation; /// -/// Wraps a mutation so governance request identifiers flow into core execution audit and history metadata. +/// Wraps mutation so governance request identifiers flow into core execution audit and history metadata. /// internal sealed class GovernedMutation : IMutation { + private const string GovernanceRequestIdMetadataKey = "GovernanceRequestId"; + private const string GovernanceRequestMetadataKey = "GovernanceRequestMetadata"; + private const string GovernanceIntentMetadataKey = "GovernanceIntentMetadata"; + private const string GovernanceEstimatedBlastRadiusMetadataKey = "GovernanceEstimatedBlastRadius"; + private readonly IMutation _inner; - public GovernedMutation(IMutation inner, string requestId, string stateId) + public GovernedMutation(IMutation inner, MutationRequest request) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + ArgumentNullException.ThrowIfNull(request); Intent = new MutationIntent { @@ -23,17 +30,17 @@ public GovernedMutation(IMutation inner, string requestId, string stateI Description = _inner.Intent.Description, RiskLevel = _inner.Intent.RiskLevel, IsReversible = _inner.Intent.IsReversible, - EstimatedBlastRadius = _inner.Intent.EstimatedBlastRadius, - Tags = _inner.Intent.Tags, + EstimatedBlastRadius = request.Intent.EstimatedBlastRadius ?? _inner.Intent.EstimatedBlastRadius, + Tags = MergeTags(request.Intent.Tags, _inner.Intent.Tags), CreatedAt = _inner.Intent.CreatedAt, - Metadata = MergeMetadata(_inner.Intent.Metadata, requestId) + Metadata = MergeIntentMetadata(request) }; Context = _inner.Context with { - StateId = string.IsNullOrWhiteSpace(_inner.Context.StateId) ? stateId : _inner.Context.StateId, - CorrelationId = string.IsNullOrWhiteSpace(_inner.Context.CorrelationId) ? requestId : _inner.Context.CorrelationId, - Metadata = MergeMetadata(_inner.Context.Metadata, requestId) + StateId = string.IsNullOrWhiteSpace(_inner.Context.StateId) ? request.StateId : _inner.Context.StateId, + CorrelationId = string.IsNullOrWhiteSpace(_inner.Context.CorrelationId) ? request.RequestId : _inner.Context.CorrelationId, + Metadata = MergeContextMetadata(request) }; } @@ -47,15 +54,40 @@ public GovernedMutation(IMutation inner, string requestId, string stateI public MutationResult Simulate(TState state) => _inner.Simulate(state); - private static IReadOnlyDictionary MergeMetadata( - IReadOnlyDictionary source, - string requestId) + private IReadOnlyDictionary MergeContextMetadata(MutationRequest request) { - var metadata = new Dictionary(source) + var metadata = new Dictionary(_inner.Context.Metadata) { - ["GovernanceRequestId"] = requestId + [GovernanceRequestIdMetadataKey] = request.RequestId }; + if (request.Metadata.Count > 0) + metadata[GovernanceRequestMetadataKey] = request.Metadata; + + if (request.Intent.Metadata.Count > 0) + metadata[GovernanceIntentMetadataKey] = request.Intent.Metadata; + + if (request.Intent.EstimatedBlastRadius is not null) + metadata[GovernanceEstimatedBlastRadiusMetadataKey] = request.Intent.EstimatedBlastRadius; + return metadata; } + + private IReadOnlyDictionary MergeIntentMetadata(MutationRequest request) + { + var metadata = new Dictionary(request.Intent.Metadata) + { + [GovernanceRequestIdMetadataKey] = request.RequestId + }; + + if (_inner.Intent.Metadata.Count > 0) + metadata["ExecutionIntentMetadata"] = _inner.Intent.Metadata; + + return metadata; + } + + private static IReadOnlySet MergeTags( + IReadOnlySet requestTags, + IReadOnlySet executionTags) + => new HashSet(requestTags.Concat(executionTags), StringComparer.Ordinal); } From f5a163e93e0f9422396b11246c8ea1a820372b09 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:34:05 +0200 Subject: [PATCH 05/15] refactor(governance-runtime): extract execution orchestration helpers --- .../GovernanceExecutionManager.cs | 81 ++++++++++++------- .../Orchestration/GovernedExecutionContext.cs | 16 ++++ ...GovernedExecutionFailureMetadataFactory.cs | 28 +++++++ 3 files changed, 98 insertions(+), 27 deletions(-) create mode 100644 src/Governance/Runtime/Execution/Orchestration/GovernedExecutionContext.cs create mode 100644 src/Governance/Runtime/Execution/Orchestration/GovernedExecutionFailureMetadataFactory.cs diff --git a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs index 2b16e54..3896187 100644 --- a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs +++ b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs @@ -46,39 +46,35 @@ public async Task> ExecuteApproved( ArgumentNullException.ThrowIfNull(resultingStateVersionProvider); ArgumentNullException.ThrowIfNull(governanceContext); - var resolution = await _resolutionManager.ResolveAndStore( + var execution = await ResolveExecutionContext( requestId, + mutation, + currentState, currentStateVersion, + resultingStateVersionProvider, governanceContext, strategy, cancellationToken).ConfigureAwait(false); - if (resolution.Outcome is MutationRequestVersionResolutionOutcome.RejectedAsStale or + if (execution.Resolution.Outcome is MutationRequestVersionResolutionOutcome.RejectedAsStale or MutationRequestVersionResolutionOutcome.RequiresRenewedApproval) { - return _outcomeHandler.BuildNonExecutedResult(resolution); + return _outcomeHandler.BuildNonExecutedResult(execution.Resolution); } - var governedMutation = new GovernedMutation(mutation, requestId, resolution.Request.StateId); MutationResult mutationResult; try { - mutationResult = await _mutationEngine - .ExecuteAsync(governedMutation, currentState, cancellationToken) - .ConfigureAwait(false); + mutationResult = await ExecuteMutation(execution, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { await _outcomeHandler.PersistRejectedExecution( - resolution.Request, - governanceContext, + execution.Resolution.Request, + execution.GovernanceContext, $"Governed execution threw '{ex.GetType().Name}': {ex.Message}", - new Dictionary - { - ["CurrentStateVersion"] = currentStateVersion, - ["ExecutionFailureType"] = ex.GetType().Name - }, + GovernedExecutionFailureMetadataFactory.CreateExceptionMetadata(execution.CurrentStateVersion, ex), cancellationToken).ConfigureAwait(false); throw; @@ -87,35 +83,32 @@ await _outcomeHandler.PersistRejectedExecution( if (!mutationResult.IsSuccess || mutationResult.NewState is null) { var rejectedRequest = await _outcomeHandler.PersistRejectedExecution( - resolution.Request, - governanceContext, + execution.Resolution.Request, + execution.GovernanceContext, GovernedExecutionDecisionFactory.BuildRejectedExecutionReason(mutationResult), - new Dictionary - { - ["CurrentStateVersion"] = currentStateVersion, - ["HasPolicyDecisions"] = mutationResult.PolicyDecisions.Count > 0, - ["HasValidationErrors"] = !mutationResult.ValidationResult.IsValid - }, + GovernedExecutionFailureMetadataFactory.CreateRejectedExecutionMetadata( + execution.CurrentStateVersion, + mutationResult), cancellationToken).ConfigureAwait(false); return _outcomeHandler.BuildNonExecutedResult( - resolution with { Request = rejectedRequest }, + execution.Resolution with { Request = rejectedRequest }, mutationResult); } - var resultingStateVersion = resultingStateVersionProvider(mutationResult.NewState); + var resultingStateVersion = execution.ResultingStateVersionProvider(mutationResult.NewState); if (string.IsNullOrWhiteSpace(resultingStateVersion)) throw new InvalidOperationException("Governed execution requires a non-empty resulting state version."); var executedRequest = await _outcomeHandler.PersistExecutedRequest( - resolution.Request, + execution.Resolution.Request, resultingStateVersion, - governanceContext, + execution.GovernanceContext, mutationResult, cancellationToken).ConfigureAwait(false); return _outcomeHandler.BuildExecutedResult( - resolution, + execution.Resolution, mutationResult, executedRequest, resultingStateVersion); @@ -138,4 +131,38 @@ public Task> ExecuteApproved( governanceContext, strategy, cancellationToken); + + private async Task> ResolveExecutionContext( + string requestId, + IMutation mutation, + TState currentState, + string currentStateVersion, + Func resultingStateVersionProvider, + MutationContext governanceContext, + VersionedRequestResolutionStrategy strategy, + CancellationToken cancellationToken) + { + var resolution = await _resolutionManager.ResolveAndStore( + requestId, + currentStateVersion, + governanceContext, + strategy, + cancellationToken).ConfigureAwait(false); + + return new GovernedExecutionContext( + resolution, + new GovernedMutation(mutation, resolution.Request), + currentState, + currentStateVersion, + resultingStateVersionProvider, + governanceContext); + } + + private Task> ExecuteMutation( + GovernedExecutionContext execution, + CancellationToken cancellationToken) + => _mutationEngine.ExecuteAsync( + execution.Mutation, + execution.CurrentState, + cancellationToken); } diff --git a/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionContext.cs b/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionContext.cs new file mode 100644 index 0000000..4ea7567 --- /dev/null +++ b/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionContext.cs @@ -0,0 +1,16 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; +using ModularityKit.Mutator.Governance.Runtime.Execution.Mutation; + +namespace ModularityKit.Mutator.Governance.Runtime.Execution.Orchestration; + +/// +/// Carries the resolved runtime inputs for one governed execution attempt. +/// +internal sealed record GovernedExecutionContext( + MutationRequestVersionResolution Resolution, + GovernedMutation Mutation, + TState CurrentState, + string CurrentStateVersion, + Func ResultingStateVersionProvider, + MutationContext GovernanceContext); diff --git a/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionFailureMetadataFactory.cs b/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionFailureMetadataFactory.cs new file mode 100644 index 0000000..4d049da --- /dev/null +++ b/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionFailureMetadataFactory.cs @@ -0,0 +1,28 @@ +using ModularityKit.Mutator.Abstractions.Results; + +namespace ModularityKit.Mutator.Governance.Runtime.Execution.Orchestration; + +/// +/// Builds consistent governance metadata payloads for execution failures. +/// +internal static class GovernedExecutionFailureMetadataFactory +{ + public static IReadOnlyDictionary CreateExceptionMetadata( + string currentStateVersion, + Exception exception) + => new Dictionary + { + ["CurrentStateVersion"] = currentStateVersion, + ["ExecutionFailureType"] = exception.GetType().Name + }; + + public static IReadOnlyDictionary CreateRejectedExecutionMetadata( + string currentStateVersion, + MutationResult mutationResult) + => new Dictionary + { + ["CurrentStateVersion"] = currentStateVersion, + ["HasPolicyDecisions"] = mutationResult.PolicyDecisions.Count > 0, + ["HasValidationErrors"] = !mutationResult.ValidationResult.IsValid + }; +} From 1df76097e50a3c2ed8ad00bb071c4c74798ce4c3 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:34:25 +0200 Subject: [PATCH 06/15] refactor(governance-runtime): align in-memory store with query namespaces --- .../Runtime/Storage/InMemoryMutationRequestStore.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs index f513697..24352a0 100644 --- a/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs +++ b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs @@ -1,6 +1,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; @@ -184,7 +186,7 @@ public Task> GetRecentApprovalsAsync( int? take = null, CancellationToken cancellationToken = default) { - var effectiveQuery = query ?? MutationRequestQuery.RecentApprovals(); + var effectiveQuery = query ?? MutationRequestQueries.RecentApprovals(); lock (_lock) { From f056c2d2b0d2b0b1385b1c6f90b755b4864ae801 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:39:24 +0200 Subject: [PATCH 07/15] docs(examples): refresh governance queries example --- .../GovernanceDecisionTaxonomyScenario.cs | 43 ++++--- Examples/Governance/Queries/README.md | 8 +- .../Scenarios/ApprovalQueryScenario.cs | 2 +- .../Scenarios/DecisionQueryScenario.cs | 2 +- .../Scenarios/GovernanceQueriesSampleData.cs | 109 +++++++++++++----- .../Queries/Scenarios/RequestQueryScenario.cs | 34 +++++- 6 files changed, 143 insertions(+), 55 deletions(-) diff --git a/Examples/Governance/DecisionTaxonomy/Scenarios/GovernanceDecisionTaxonomyScenario.cs b/Examples/Governance/DecisionTaxonomy/Scenarios/GovernanceDecisionTaxonomyScenario.cs index 07a8dd1..a2e52b0 100644 --- a/Examples/Governance/DecisionTaxonomy/Scenarios/GovernanceDecisionTaxonomyScenario.cs +++ b/Examples/Governance/DecisionTaxonomy/Scenarios/GovernanceDecisionTaxonomyScenario.cs @@ -8,40 +8,49 @@ internal static class GovernanceDecisionTaxonomyScenario public static void Run() { PrintSection("Lifecycle Decisions"); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + + PrintDecision(MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, MutationContext.User("requester", "Requester", "Submit request"), reason: "Request was submitted into governance.")); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + + PrintDecision(MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, MutationContext.User("system", "System", "Request reached executable state"), reason: "Request is now approved for execution.")); + PrintSection("Approval Decisions"); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), + + PrintDecision(MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Requested, MutationContext.User("requester", "Requester", "Approval needed for sensitive change"), reason: "Sensitive change requires explicit sign-off.")); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted), + + PrintDecision(MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Granted, MutationContext.User("alice", "Alice", "Manager approved"), reason: "Manager granted the required approval.")); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Rejected), + + PrintDecision(MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Rejected, MutationContext.User("bob", "Bob", "Security rejected"), reason: "Security review rejected the request.")); PrintSection("Version Resolution Decisions"); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.Validated), + + PrintDecision(MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.Validated, MutationContext.User("approver", "Approver", "Version still matches"), reason: "Current state version still matches the approved request.")); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RejectedAsStale), + + PrintDecision(MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.RejectedAsStale, MutationContext.User("approver", "Approver", "State drift invalidated the request"), reason: "Request was rejected because the approved version is stale.")); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RenewedApprovalRequired), + + PrintDecision(MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.RenewedApprovalRequired, MutationContext.User("approver", "Approver", "Re-approval required on latest state"), reason: "Request must be approved again on the latest state version.")); } @@ -61,4 +70,4 @@ private static void PrintDecision(MutationRequestDecision decision) Console.WriteLine($"Reason: {decision.Reason ?? "-"}"); Console.WriteLine(); } -} +} \ No newline at end of file diff --git a/Examples/Governance/Queries/README.md b/Examples/Governance/Queries/README.md index 3b03a16..66aa96e 100644 --- a/Examples/Governance/Queries/README.md +++ b/Examples/Governance/Queries/README.md @@ -7,6 +7,7 @@ It focuses on listing governed requests, approval work, and decision history wit ## What it demonstrates - querying governed requests with `MutationRequestQuery` +- filtering by intent tags, intent metadata, request metadata, and blast radius - listing the pending approval queue through `IMutationRequestQueryStore` - listing pending requests by `PendingMutationReason` - querying requests by `StateId` and request category @@ -25,9 +26,9 @@ It focuses on listing governed requests, approval work, and decision history wit - [`Scenarios/ApprovalQueryScenario.cs`](Scenarios/ApprovalQueryScenario.cs) - [`Scenarios/DecisionQueryScenario.cs`](Scenarios/DecisionQueryScenario.cs) - [`src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs`](../../../src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs) -- [`src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs`](../../../src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs) -- [`src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs`](../../../src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs) -- [`src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs`](../../../src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs) +- [`src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs`](../../../src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs) +- [`src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQuery.cs`](../../../src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQuery.cs) +- [`src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQuery.cs`](../../../src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQuery.cs) ## Run @@ -43,6 +44,7 @@ The sample prints: - pending external-check requests - requests filtered by request category - requests filtered by state +- requests filtered by governance metadata - recent approval-driven requests - approval views filtered by approver - recent version-resolution decisions diff --git a/Examples/Governance/Queries/Scenarios/ApprovalQueryScenario.cs b/Examples/Governance/Queries/Scenarios/ApprovalQueryScenario.cs index fea182b..744ba2e 100644 --- a/Examples/Governance/Queries/Scenarios/ApprovalQueryScenario.cs +++ b/Examples/Governance/Queries/Scenarios/ApprovalQueryScenario.cs @@ -1,5 +1,5 @@ using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; namespace Queries.Scenarios; diff --git a/Examples/Governance/Queries/Scenarios/DecisionQueryScenario.cs b/Examples/Governance/Queries/Scenarios/DecisionQueryScenario.cs index 4df2150..b60554f 100644 --- a/Examples/Governance/Queries/Scenarios/DecisionQueryScenario.cs +++ b/Examples/Governance/Queries/Scenarios/DecisionQueryScenario.cs @@ -1,5 +1,5 @@ using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; namespace Queries.Scenarios; diff --git a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs index b51f40f..15e796e 100644 --- a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs +++ b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs @@ -3,6 +3,8 @@ using ModularityKit.Mutator.Abstractions.Policies; using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; @@ -76,7 +78,7 @@ public static void PrintRequests(IReadOnlyList requests) Console.WriteLine("- none"); } - public static void PrintApprovals(IReadOnlyList approvals) + public static void PrintApprovals(IReadOnlyList approvals) { foreach (var approval in approvals) { @@ -88,7 +90,7 @@ public static void PrintApprovals(IReadOnlyList decisions) + public static void PrintDecisions(IReadOnlyList decisions) { foreach (var decision in decisions) { @@ -114,7 +116,13 @@ private static MutationRequest CreatePendingApprovalRequest( { OperationName = "ExampleOperation", Category = category, - Description = $"Governed request for {category.ToLowerInvariant()} flow" + Description = $"Governed request for {category.ToLowerInvariant()} flow", + Tags = new HashSet { category.ToLowerInvariant(), "approval" }, + EstimatedBlastRadius = category == "Security" ? BlastRadius.Module : BlastRadius.Single, + Metadata = new Dictionary + { + ["risk-owner"] = category == "Security" ? "platform" : "finance" + } }, context: MutationContext.User("requester", "Requester", "Need governed change"), requirements: @@ -126,6 +134,10 @@ private static MutationRequest CreatePendingApprovalRequest( RequestId = requestId, CreatedAt = createdAt, UpdatedAt = createdAt, + Metadata = new Dictionary + { + ["ticket"] = category == "Security" ? "INC-42" : "BILL-7" + }, ApprovalRequirements = [ new MutationApprovalRequirement @@ -150,7 +162,13 @@ private static MutationRequest CreateExternalCheckRequest( { OperationName = "ExampleOperation", Category = category, - Description = "Waiting for dependency validation" + Description = "Waiting for dependency validation", + Tags = new HashSet { "external-check" }, + EstimatedBlastRadius = BlastRadius.Single, + Metadata = new Dictionary + { + ["risk-owner"] = "release" + } }, context: MutationContext.Service("release-orchestrator", "Waiting for external dependency"), pendingReason: PendingMutationReason.ExternalCheck) @@ -158,7 +176,11 @@ private static MutationRequest CreateExternalCheckRequest( { RequestId = requestId, CreatedAt = createdAt, - UpdatedAt = createdAt + UpdatedAt = createdAt, + Metadata = new Dictionary + { + ["ticket"] = "REL-99" + } }; private static MutationRequest CreateRecentlyApprovedRequest( @@ -175,7 +197,13 @@ private static MutationRequest CreateRecentlyApprovedRequest( { OperationName = "ExampleOperation", Category = category, - Description = "Recently approved governed request" + Description = "Recently approved governed request", + Tags = new HashSet { category.ToLowerInvariant(), "approved" }, + EstimatedBlastRadius = category == "Security" ? BlastRadius.Module : BlastRadius.Single, + Metadata = new Dictionary + { + ["risk-owner"] = category == "Security" ? "platform" : "finance" + } }, context: MutationContext.User("requester", "Requester", "Need privileged change"), requirements: @@ -189,6 +217,10 @@ private static MutationRequest CreateRecentlyApprovedRequest( PendingReason = null, CreatedAt = approvedAt.AddMinutes(-20), UpdatedAt = approvedAt, + Metadata = new Dictionary + { + ["ticket"] = category == "Security" ? "INC-77" : "BILL-9" + }, ApprovalRequirements = [ new MutationApprovalRequirement @@ -201,36 +233,36 @@ private static MutationRequest CreateRecentlyApprovedRequest( ], Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, MutationContext.User("requester", "Requester", "Submitted")) with { Timestamp = approvedAt.AddMinutes(-20) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Pending, MutationContext.User("requester", "Requester", "Pending approval")) with { Timestamp = approvedAt.AddMinutes(-19) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), + MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Requested, MutationContext.User("requester", "Requester", "Approval requested")) with { Timestamp = approvedAt.AddMinutes(-18) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted), + MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Granted, MutationContext.User(approverId, approverId, "Approved")) with { Timestamp = approvedAt }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, MutationContext.User(approverId, approverId, "Approved")) with { @@ -252,7 +284,13 @@ private static MutationRequest CreateResolvedRequest( { OperationName = "ExampleOperation", Category = category, - Description = "Resolved governed request" + Description = "Resolved governed request", + Tags = new HashSet { category.ToLowerInvariant(), "resolution" }, + EstimatedBlastRadius = BlastRadius.Single, + Metadata = new Dictionary + { + ["risk-owner"] = "governance" + } }, context: MutationContext.Service("governance-runtime", "Resolve stale request")) with @@ -261,18 +299,21 @@ private static MutationRequest CreateResolvedRequest( Status = MutationRequestStatus.Approved, CreatedAt = decisionTimestamp.AddMinutes(-30), UpdatedAt = decisionTimestamp, + Metadata = new Dictionary + { + ["ticket"] = "CFG-5" + }, Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, MutationContext.Service("governance-runtime", "Submitted")) with { Timestamp = decisionTimestamp.AddMinutes(-30) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution( - MutationRequestVersionResolutionDecisionType.Validated), + MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.Validated, MutationContext.Service("governance-runtime", "Validated current version")) with { @@ -294,7 +335,13 @@ private static MutationRequest CreateExecutedRequest( { OperationName = "ExampleOperation", Category = category, - Description = "Executed governed request" + Description = "Executed governed request", + Tags = new HashSet { category.ToLowerInvariant(), "executed" }, + EstimatedBlastRadius = BlastRadius.Single, + Metadata = new Dictionary + { + ["risk-owner"] = "finance" + } }, context: MutationContext.Service("governance-runtime", "Execute approved request")) with @@ -304,24 +351,28 @@ private static MutationRequest CreateExecutedRequest( CreatedAt = executedAt.AddMinutes(-15), UpdatedAt = executedAt, ExecutedAt = executedAt, + Metadata = new Dictionary + { + ["ticket"] = "BILL-22" + }, Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, MutationContext.Service("governance-runtime", "Submitted")) with { Timestamp = executedAt.AddMinutes(-15) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, MutationContext.Service("governance-runtime", "Approved")) with { Timestamp = executedAt.AddMinutes(-5) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Executed, MutationContext.Service("governance-runtime", "Executed")) with { diff --git a/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs b/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs index 9df4cb5..b3a223d 100644 --- a/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs +++ b/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs @@ -1,6 +1,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Abstractions.Intent; namespace Queries.Scenarios; @@ -14,19 +16,43 @@ public static async Task Run(IMutationRequestQueryStore queryStore) GovernanceQueriesSampleData.PrintSection("Pending External Check Requests"); GovernanceQueriesSampleData.PrintRequests(await queryStore.GetPendingRequestsAsync(new MutationRequestQuery { - PendingReasons = new HashSet { PendingMutationReason.ExternalCheck } + Lifecycle = new MutationRequestLifecycleFilter + { + PendingReasons = new HashSet { PendingMutationReason.ExternalCheck } + } })); GovernanceQueriesSampleData.PrintSection("Billing Requests"); GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery { - Categories = new HashSet { "Billing" } + Intent = new MutationRequestIntentFilter + { + Categories = new HashSet { "Billing" } + } })); GovernanceQueriesSampleData.PrintSection("Requests For tenant-42:roles"); GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery { - StateIds = new HashSet { "tenant-42:roles" } + Scope = new MutationRequestScopeFilter + { + StateIds = new HashSet { "tenant-42:roles" } + } + })); + + GovernanceQueriesSampleData.PrintSection("Metadata-classified Security Requests"); + GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery + { + Intent = new MutationRequestIntentFilter + { + Tags = new HashSet { "security" }, + Metadata = new Dictionary { ["risk-owner"] = "platform" }, + MinimumBlastRadiusScope = BlastRadiusScope.Module + }, + Metadata = new MutationRequestMetadataFilter + { + Values = new Dictionary { ["ticket"] = "INC-42" } + } })); GovernanceQueriesSampleData.PrintSection("Recent Approval Driven Requests"); From 4e7afe16060927ab52ff7b8657ad55803e909c48 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:43:05 +0200 Subject: [PATCH 08/15] docs(governance): add query model ADR and API guides --- Docs/{ => API}/API-Reference.md | 16 +- Docs/API/API.md | 158 ++++++++++++++++++ Docs/API/Redis.md | 136 +++++++++++++++ ...33_Governance_Query_Model_Decomposition.md | 86 ++++++++++ Docs/Decision/listadr.md | 1 + src/Governance/README.md | 10 ++ src/Redis/README.md | 2 + 7 files changed, 408 insertions(+), 1 deletion(-) rename Docs/{ => API}/API-Reference.md (93%) create mode 100644 Docs/API/API.md create mode 100644 Docs/API/Redis.md create mode 100644 Docs/Decision/Adr/ADR_033_Governance_Query_Model_Decomposition.md diff --git a/Docs/API-Reference.md b/Docs/API/API-Reference.md similarity index 93% rename from Docs/API-Reference.md rename to Docs/API/API-Reference.md index ac304df..53d7e9b 100644 --- a/Docs/API-Reference.md +++ b/Docs/API/API-Reference.md @@ -15,6 +15,7 @@ Complete API documentation for **ModularityKit.Mutators**. - [Related Types](#related-types) - [DI Extension Methods](#di-extension-methods) - [AddMutators](#addmutators) +- [Governance API](#governance-api) - [Execution Semantics](#execution-semantics) - [Best Practices](#best-practices) - [Complete Examples](#complete-examples) @@ -246,6 +247,19 @@ services.AddMutators(MutationEngineOptions.Strict, addDefaultLoggingInterceptor: --- +## Governance API + +Governance is documented separately so the core API reference can stay focused on direct mutation execution. + +- [Governance package overview](../../src/Governance/README.md) +- [Governance API usage](API.md) +- [Redis provider API](Redis.md) + +Governance-specific types such as `MutationRequestFactory`, `MutationRequestDecision`, and +`IGovernanceExecutionManager` live in the governance package, not the core mutation runtime. + +--- + ## Execution Semantics 1. Call `Validate(state)` @@ -272,4 +286,4 @@ services.AddMutators(MutationEngineOptions.Strict, addDefaultLoggingInterceptor: --- -> **Built with ❤️ for .NET developers** \ No newline at end of file +> **Built with ❤️ for .NET developers** diff --git a/Docs/API/API.md b/Docs/API/API.md new file mode 100644 index 0000000..f72b572 --- /dev/null +++ b/Docs/API/API.md @@ -0,0 +1,158 @@ +# Governance API + +This page shows the practical entry points used by the governance examples. + +## What this API is for + +Use the governance API when execution is not immediate and you need one of these steps first: + +- request creation +- approval workflow +- stale-version resolution +- governed execution +- request and decision history queries + +The core package owns direct mutation execution. Governance wraps that execution with a request +model and stateful decision history. + +## Request creation + +Use `MutationRequestFactory` when you want to create a request from explicit strings and values: + +```csharp +var request = MutationRequestFactory.PendingApproval( + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated role to tenant operator" + }, + context: MutationContext.User("requester-1", "Requester One", "Incident escalation"), + expectedStateVersion: "v10", + approvalRequirements: + [ + MutationApprovalRequirement.SingleActorStep("security-lead"), + MutationApprovalRequirement.SingleActorStep("platform-owner") + ]); +``` + +If you already have concrete CLR types, prefer the generic overloads such as: + +- `MutationRequestFactory.Approved()` +- `MutationRequestFactory.PendingApproval()` + +They remove repeated `stateType` and `mutationType` strings at the call site. + +### Factory choices + +- `Pending(...)` for a request that should enter the lifecycle without approval requirements +- `PendingApproval(...)` for a request that must collect one or more approval steps +- `Approved(...)` for a request that is terminally approved at creation time + +Use the generic overloads when the CLR types already exist. Use the string-based overloads when the +request metadata comes from an external system or serialized payload. + +## Decision history + +Use `MutationRequestDecision` when you need to record a lifecycle, approval, or version-resolution decision and the category is already known: + +```csharp +var decision = MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, + MutationContext.System("governance-runtime", "Approved by governance")); +``` + +The category-specific helpers are: + +- `MutationRequestDecision.Lifecycle(...)` +- `MutationRequestDecision.Approval(...)` +- `MutationRequestDecision.VersionResolution(...)` + +Use the low-level `MutationRequestDecision.Create(...)` only when the decision type is already dynamic and you do not know the category up front. + +### Decision categories + +- lifecycle decisions describe request state transitions such as submitted, pending, approved, rejected, executed, canceled, or expired +- approval decisions describe workflow-level approval actions such as requested, approved, rejected, or quorum satisfied +- version-resolution decisions describe how the runtime handled stale or matching versions before execution + +The helper methods exist so call sites stay readable when the category is already known. + +## Governed execution + +Use `IGovernanceExecutionManager.ExecuteApproved(...)` when the runtime should resolve an approved request and execute the mutation: + +```csharp +var execution = await executionManager.ExecuteApproved( + request.RequestId, + mutation, + currentState, + governanceContext: MutationContext.Service("governance-runtime", "Execute approved governance request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); +``` + +If your state implements `IVersionedState`, use the overload that accepts `currentState` directly and reads `state.Version` for both version selectors. + +### Typical execution flow + +1. create or load a request +2. approve the request if needed +3. resolve the request against the current state version +4. execute the underlying mutation through the core engine +5. record the terminal request decision + +That flow is what the `GovernedExecution` example demonstrates end to end. + +## Common operations + +### Create a pending approval request + +```csharp +var request = MutationRequestFactory.PendingApproval( + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated role to tenant operator" + }, + context: MutationContext.User("requester-1", "Requester One", "Incident escalation"), + expectedStateVersion: "v10", + approvalRequirements: + [ + MutationApprovalRequirement.SingleActorStep("security-lead"), + MutationApprovalRequirement.SingleActorStep("platform-owner") + ]); +``` + +### Record an approval decision + +```csharp +var decision = MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Approved, + MutationContext.User("approver-1", "Approver One", "Approved after review")); +``` + +### Execute an already approved request + +```csharp +var result = await executionManager.ExecuteApproved( + request.RequestId, + mutation, + currentState, + governanceContext: MutationContext.Service("governance-runtime", "Execute approved governance request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); +``` + +## Where to look next + +- `README.md` for the package overview +- `Examples/Governance/*/README.md` for runnable scenarios +- `src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs` for request creation +- `src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs` for decision helpers +- `src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs` for governed execution diff --git a/Docs/API/Redis.md b/Docs/API/Redis.md new file mode 100644 index 0000000..ada25f1 --- /dev/null +++ b/Docs/API/Redis.md @@ -0,0 +1,136 @@ +# Redis Governance Provider API + +This page covers the Redis-backed provider package `ModularityKit.Mutator.Governance.Redis`. + +It is the storage and query backend for governed requests. The package implements the public +request store and query store contracts while keeping Redis-specific persistence details internal. + +## What this API is for + +Use the Redis provider when you want the governance model from `ModularityKit.Mutator.Governance` +but need a persistent backing store instead of in-memory state. + +Typical reasons: + +- you want request persistence across process restarts +- you want Redis-backed query projections for approval queues and decision history +- you want the same governance API surface with a different storage backend +- you want to share governed request state across multiple runtime instances + +## Primary APIs + +### Dependency injection + +- `RedisGovernanceServiceCollectionExtensions.AddRedisGovernanceStore(...)` + +Registers the Redis-backed governance store and the query store against an existing +`IConnectionMultiplexer`. + +```csharp +using Microsoft.Extensions.DependencyInjection; +using ModularityKit.Mutator.Governance.Redis; +using StackExchange.Redis; + +var services = new ServiceCollection(); +var multiplexer = await ConnectionMultiplexer.ConnectAsync("localhost:6379"); + +services.AddRedisGovernanceStore( + multiplexer, + options => options.KeyPrefix = "modularitykit:governance"); +``` + +The extension method registers the provider-facing storage and query services against DI. + +### Configuration details + +`RedisMutationRequestStoreOptions.KeyPrefix` scopes all Redis keys created by the provider. + +Use a dedicated prefix per environment or application if you need isolation between deployments. +The examples use `modularitykit:governance` as a readable default. + +### Configuration + +- `RedisMutationRequestStoreOptions` + +Current option: + +- `KeyPrefix` - Redis key prefix used for all request data, indexes, and query projections + +### Storage facade + +- `RedisMutationRequestStore` + +Implements: + +- `IMutationRequestStore` +- `IMutationRequestQueryStore` + +Use it through DI rather than constructing it directly unless you are testing internals. + +### What gets stored + +The provider stores the data needed for: + +- request documents +- decision and approval projections +- query indexes and queue membership +- optimistic concurrency state for request writes + +The provider does not change the governance model. It only persists the same model through Redis. + +### Keyspace and key helpers + +- `RedisMutationRequestKeyspace` +- `RedisMutationRequestDocumentKeyFactory` + +These types define the Redis naming and partitioning rules for request documents, indexes, and +query views. + +## What the provider does + +The Redis provider handles: + +- request persistence +- query projection +- optimistic concurrency on request writes +- Redis index maintenance +- candidate selection for query execution + +It does not change the governance model itself. It only replaces the in-memory storage backend with +Redis-specific persistence and read paths. + +## Common usage + +### Register the provider + +```csharp +services.AddRedisGovernanceStore( + multiplexer, + options => options.KeyPrefix = "modularitykit:governance"); +``` + +### Resolve the stores + +```csharp +var requestStore = provider.GetRequiredService(); +var queryStore = provider.GetRequiredService(); +``` + +### Use the normal governance APIs + +Once registered, the rest of the application uses the same request and query contracts as the +in-memory provider. + +## Usage pattern + +1. Connect to Redis with `StackExchange.Redis` +2. Register the provider with `AddRedisGovernanceStore(...)` +3. Resolve `IMutationRequestStore` and `IMutationRequestQueryStore` from DI +4. Use the normal governance request and query APIs + +## Related docs + +- [`src/Redis/README.md`](../../src/Redis/README.md) +- [`Examples/Governance/RedisQueries/README.md`](../../Examples/Governance/RedisQueries/README.md) +- [`API-Reference.md`](API-Reference.md) +- [Governance API](API.md) diff --git a/Docs/Decision/Adr/ADR_033_Governance_Query_Model_Decomposition.md b/Docs/Decision/Adr/ADR_033_Governance_Query_Model_Decomposition.md new file mode 100644 index 0000000..56aa183 --- /dev/null +++ b/Docs/Decision/Adr/ADR_033_Governance_Query_Model_Decomposition.md @@ -0,0 +1,86 @@ +# ADR-033: Governance Query Model Decomposition + +## Tag +#adr_033 + +## Status +Accepted + +## Date +2026-06-30 + +## Scope +ModularityKit.Mutator.Governance.Abstractions.Queries + +## Context + +ADR-026 established that governance needs storage agnostic query API, but it intentionally left the exact query object model open. + +As the query surface grew, `MutationRequestQuery` started to accumulate several distinct concerns: + +- request and state identity filters +- actor filters +- intent classification filters such as category, tags, metadata, and blast radius +- lifecycle and decision-history filters +- approval oriented and decision oriented projections adjacent to the same flat namespace + +Without more explicit structure, the query model would keep growing as one wide record with loosely related fields. That creates several problems: + +- the request query type becomes harder to read and evolve +- approval and decision query types remain mixed into the same flat model namespace +- future additions such as risk level or execution specific filters would further flatten unrelated concepts into one type + +## Decision + +The governance query abstractions should be decomposed into grouped models and organized by query concern. + +Request query shape: + +- `MutationRequestQuery` remains the root request query contract +- request query filters are grouped into explicit components: + - `Scope` + - `Actor` + - `Intent` + - `Metadata` + - `Lifecycle` + - `TimeRange` +- request query presets move into `MutationRequestQueries` + +Namespace and folder organization: + +- request query abstractions live under `...Queries.Model.Requests` +- request query filter types live under `...Queries.Model.Requests.Filters` +- approval query types live under `...Queries.Model.Approvals` +- decision query types live under `...Queries.Model.Decisions` + +Evaluation semantics: + +- query evaluators compose groupspecific matches rather than expanding one flat request query type indefinitely +- approval and decision query types continue to depend on the request query contract where needed, but remain organized as distinct concerns + +## Design Rationale + +- The request query contract stays explicit without becoming one ever growing bag of filters. +- Filter grouping makes new additions easier to place without weakening the model. +- Approval and decision queries are part of the same read side, but not the same concern as request filters, so they should not share one flat namespace. +- Moving presets out of `MutationRequestQuery` keeps the model closer to a DTO and avoids mixing data shape with convenience factories. + +## Consequences + +### Positive + +- The query model is easier to scan and extend. +- Filter placement becomes more disciplined as the governance read side grows. +- Namespace organization better reflects actual query responsibilities. +- The query API stays storage agnostic while becoming more explicit internally. + +### Negative + +- Consumers need a small amount of additional object construction when building request queries. +- Existing code that referenced the old flat model or flat namespaces must be updated. +- Some changes that are semantically query related now span several small types instead of one file. + +## Related ADRs + +- ADR-026: Governance Request Query API +- ADR-030: Governance Redis Request Storage and Query Strategy diff --git a/Docs/Decision/listadr.md b/Docs/Decision/listadr.md index 08d6567..5c089ae 100644 --- a/Docs/Decision/listadr.md +++ b/Docs/Decision/listadr.md @@ -48,5 +48,6 @@ These ADRs describe the `ModularityKit.Mutator.Governance` extension layer and i | ADR-030 | Governance Redis Request Storage and Query Strategy | [ADR-030](Adr/ADR_030_Governance_Redis_Request_Storage_and_Query_Strategy.md) | | ADR-031 | Governance Redis Serialization and Document Compatibility | [ADR-031](Adr/ADR_031_Governance_Redis_Serialization_and_Document_Compatibility.md) | | ADR-032 | Governance Redis Concurrency and Index Maintenance Model | [ADR-032](Adr/ADR_032_Governance_Redis_Concurrency_and_Index_Maintenance_Model.md) | +| ADR-033 | Governance Query Model Decomposition | [ADR-033](Adr/ADR_033_Governance_Query_Model_Decomposition.md) | > See individual ADRs for detailed context, decision rationale, and consequences. diff --git a/src/Governance/README.md b/src/Governance/README.md index e5b67d1..c497cda 100644 --- a/src/Governance/README.md +++ b/src/Governance/README.md @@ -48,6 +48,10 @@ The request factory also has generic overloads such as `Approved()` when you already have concrete CLR types and do not want to repeat `stateType` / `mutationType` strings at the call site. +`MutationRequestDecision` also exposes category-specific helpers such as `Lifecycle(...)`, +`Approval(...)`, and `VersionResolution(...)` so you do not have to spell out the low-level +decision wrapper when the category is already known. + ### Storage - `IMutationRequestStore` @@ -87,6 +91,12 @@ to repeat `stateType` / `mutationType` strings at the call site. - `GovernanceExecutionManager` - `GovernedExecutionResult` +`IGovernanceExecutionManager.ExecuteApproved(...)` also has an overload for `IVersionedState` +implementations, which removes the need to pass the current and resulting version selectors when +both come from `state.Version`. + +See [`Docs/API/API.md`](../../Docs/API/API.md) for the practical usage surface and example call patterns. + ## Package structure The project is organized by governance concern: diff --git a/src/Redis/README.md b/src/Redis/README.md index 93c60c3..7357a7a 100644 --- a/src/Redis/README.md +++ b/src/Redis/README.md @@ -1,5 +1,7 @@ ![ModularityKit.Mutator.Governance.Redis overview](../../assets/governance/providers/mutator-governance-redis-overview.png) +See [`Docs/API/Redis.md`](../../Docs/API/Redis.md) for the practical API surface and usage examples. + ## Package structure - `Configuration` for provider options From 3dca9f285a9e35a90524209b28e21e1dcc8bb2ff Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:51:11 +0200 Subject: [PATCH 09/15] refactor(redis): align query candidate planning with grouped filters --- ...edisMutationRequestCandidatePlanBuilder.cs | 32 +++++++++---------- ...isMutationRequestQueryCandidateSelector.cs | 12 +++---- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/Redis/Storage/Candidates/Planning/RedisMutationRequestCandidatePlanBuilder.cs b/src/Redis/Storage/Candidates/Planning/RedisMutationRequestCandidatePlanBuilder.cs index dd08afd..dc57095 100644 --- a/src/Redis/Storage/Candidates/Planning/RedisMutationRequestCandidatePlanBuilder.cs +++ b/src/Redis/Storage/Candidates/Planning/RedisMutationRequestCandidatePlanBuilder.cs @@ -1,5 +1,5 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Redis.Keys; using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Models; using StackExchange.Redis; @@ -7,21 +7,21 @@ namespace ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Planning; /// -/// Builds candidate-id lookup plans for Redis-backed request queries. +/// Builds candidate id lookup plans for Redis backed request queries. /// internal sealed class RedisMutationRequestCandidatePlanBuilder(RedisMutationRequestKeyspace keyspace) { private readonly RedisMutationRequestKeyspace _keyspace = keyspace ?? throw new ArgumentNullException(nameof(keyspace)); /// - /// Builds a plan that loads all known request identifiers. + /// Builds plan that loads all known request identifiers. /// /// The candidate plan. public RedisMutationRequestCandidatePlan BuildAllRequestsPlan() => Single(_keyspace.RequestIds()); /// - /// Builds a plan that loads request identifiers for a specific state. + /// Builds plan that loads request identifiers for specific state. /// /// The state identifier. /// The candidate plan. @@ -29,7 +29,7 @@ public RedisMutationRequestCandidatePlan BuildByStateIdPlan(string stateId) => Single(_keyspace.RequestsByStateId(stateId)); /// - /// Builds a plan that loads pending request identifiers, optionally narrowed by pending reason. + /// Builds plan that loads pending request identifiers, optionally narrowed by pending reason. /// /// The optional pending reason. /// The candidate plan. @@ -37,7 +37,7 @@ public RedisMutationRequestCandidatePlan BuildPendingPlan(PendingMutationReason? => Single(GetPendingKey(reason)); /// - /// Builds a plan that loads pending request identifiers for a specific state. + /// Builds plan that loads pending request identifiers for specific state. /// /// The state identifier. /// The optional pending reason. @@ -46,7 +46,7 @@ public RedisMutationRequestCandidatePlan BuildPendingByStateIdPlan(string stateI => Intersect(_keyspace.RequestsByStateId(stateId), GetPendingKey(reason)); /// - /// Builds a best-effort Redis candidate plan for the supplied request query. + /// Builds best effort Redis candidate plan for the supplied request query. /// /// The request query to analyze. /// The candidate plan. @@ -54,22 +54,22 @@ public RedisMutationRequestCandidatePlan BuildQueryPlan(MutationRequestQuery que { ArgumentNullException.ThrowIfNull(query); - if (query.RequestIds.Count > 0) - return Explicit(query.RequestIds); + if (query.Scope.RequestIds.Count > 0) + return Explicit(query.Scope.RequestIds); - if (query.PendingReasons.Count > 0) - return Union(query.PendingReasons.Select(_keyspace.PendingRequestIds)); + if (query.Lifecycle.PendingReasons.Count > 0) + return Union(query.Lifecycle.PendingReasons.Select(_keyspace.PendingRequestIds)); - if (query.Statuses.Count > 0) + if (query.Lifecycle.Statuses.Count > 0) { - var pendingOnly = query.Statuses.All(status => status == MutationRequestStatus.Pending); + var pendingOnly = query.Lifecycle.Statuses.All(status => status == MutationRequestStatus.Pending); return pendingOnly ? BuildPendingPlan(reason: null) - : Union(query.Statuses.Select(_keyspace.RequestsByStatus)); + : Union(query.Lifecycle.Statuses.Select(_keyspace.RequestsByStatus)); } - if (query.StateIds.Count > 0) - return Union(query.StateIds.Select(_keyspace.RequestsByStateId)); + if (query.Scope.StateIds.Count > 0) + return Union(query.Scope.StateIds.Select(_keyspace.RequestsByStateId)); return BuildAllRequestsPlan(); } diff --git a/src/Redis/Storage/Candidates/RedisMutationRequestQueryCandidateSelector.cs b/src/Redis/Storage/Candidates/RedisMutationRequestQueryCandidateSelector.cs index b642e19..78805c0 100644 --- a/src/Redis/Storage/Candidates/RedisMutationRequestQueryCandidateSelector.cs +++ b/src/Redis/Storage/Candidates/RedisMutationRequestQueryCandidateSelector.cs @@ -1,5 +1,5 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Execution; using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Models; using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Planning; @@ -7,7 +7,7 @@ namespace ModularityKit.Mutator.Governance.Redis.Storage.Candidates; /// -/// Selects Redis request-id candidates for higher-level request queries. +/// Selects request id candidates for higher level request queries. /// internal sealed class RedisMutationRequestQueryCandidateSelector( RedisMutationRequestCandidatePlanBuilder planBuilder, @@ -15,7 +15,7 @@ internal sealed class RedisMutationRequestQueryCandidateSelector( { private readonly RedisMutationRequestCandidatePlanBuilder _planBuilder = planBuilder ?? throw new ArgumentNullException(nameof(planBuilder)); - + private readonly RedisMutationRequestCandidateExecutor _candidateExecutor = candidateExecutor ?? throw new ArgumentNullException(nameof(candidateExecutor)); @@ -28,7 +28,7 @@ public Task> LoadAllRequestIdsAsync(CancellationToken canc LoadAsync(_planBuilder.BuildAllRequestsPlan(), cancellationToken); /// - /// Loads request identifiers for a specific state. + /// Loads request identifiers for specific state. /// /// The state identifier. /// The cancellation token. @@ -46,7 +46,7 @@ public Task> LoadPendingAsync(PendingMutationReason? reaso LoadAsync(_planBuilder.BuildPendingPlan(reason), cancellationToken); /// - /// Loads pending request identifiers for a specific state. + /// Loads pending request identifiers for specific state. /// /// The state identifier. /// The optional pending reason. @@ -56,7 +56,7 @@ public Task> LoadPendingByStateIdAsync(string stateId, Pen LoadAsync(_planBuilder.BuildPendingByStateIdPlan(stateId, reason), cancellationToken); /// - /// Loads request identifiers for a general request query using Redis-side candidate narrowing. + /// Loads request identifiers for general request query using side candidate narrowing. /// /// The query to analyze. /// The cancellation token. From 3c7e010e7c377f0dbd27fc45bfa7325f64065460 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:51:24 +0200 Subject: [PATCH 10/15] refactor(redis): align query reading with decomposed query models --- .../RedisMutationRequestOrdering.cs | 26 ++++++++++++++++++- .../RedisMutationRequestQueryMaterializer.cs | 16 +++++++----- .../RedisMutationRequestViewProjector.cs | 13 +++++++++- ...RedisMutationRequestQueryDocumentLoader.cs | 2 +- .../RedisMutationRequestQueryReader.cs | 6 +++-- .../Storage/RedisMutationRequestStore.cs | 16 +++++++----- 6 files changed, 60 insertions(+), 19 deletions(-) diff --git a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs index c035202..033076f 100644 --- a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs +++ b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs @@ -1,4 +1,6 @@ -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; @@ -8,12 +10,23 @@ namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization /// internal static class RedisMutationRequestOrdering { + /// + /// Orders request results by creation time and request identifier. + /// + /// Requests to order. + /// Materialized request results in ascending creation order. public static IReadOnlyList ByCreated(IEnumerable requests) => requests .OrderBy(request => request.CreatedAt) .ThenBy(request => request.RequestId) .ToList(); + /// + /// Orders requests by the most recent approval activity and applies an optional result limit. + /// + /// Requests to order. + /// Optional maximum number of results to return. + /// Materialized request results ordered for recent approval views. public static IReadOnlyList ByRecentApprovals( IEnumerable requests, int? take) @@ -29,6 +42,11 @@ public static IReadOnlyList ByRecentApprovals( return results.ToList(); } + /// + /// Orders pending approval projections by request creation and approval step sequence. + /// + /// Approval views to order. + /// Materialized approval views in pending queue order. public static IReadOnlyList ByPendingApprovalView( IEnumerable views) => views @@ -38,6 +56,12 @@ public static IReadOnlyList ByPendingApprovalView( .ThenBy(view => view.Approval.ApprovalId) .ToList(); + /// + /// Orders decision projections by decision recency and applies an optional result limit. + /// + /// Decision views to order. + /// Optional maximum number of results to return. + /// Materialized decision views ordered from newest to oldest. public static IReadOnlyList ByRecentDecisionView( IEnumerable views, int? take) diff --git a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs index 18a128a..b5ff76e 100644 --- a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs +++ b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs @@ -1,5 +1,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; @@ -10,7 +12,7 @@ namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization internal static class RedisMutationRequestQueryMaterializer { /// - /// Applies a general request query to already materialized requests. + /// Applies general request query to already materialized requests. /// /// The materialized requests. /// The query to evaluate. @@ -27,7 +29,7 @@ public static IReadOnlyList ApplyQuery( } /// - /// Applies a pending request query to already materialized requests. + /// Applies pending request query to already materialized requests. /// /// The materialized requests. /// The query to evaluate. @@ -46,7 +48,7 @@ public static IReadOnlyList ApplyPendingQuery( } /// - /// Applies a pending approval queue query to already materialized requests. + /// Applies pending approval queue query to already materialized requests. /// /// The materialized requests. /// The query to evaluate. @@ -66,7 +68,7 @@ public static IReadOnlyList ApplyPendingApprovalQueueQuery( } /// - /// Applies a recent approvals query to already materialized requests. + /// Applies recent approvals query to already materialized requests. /// /// The materialized requests. /// The query to evaluate. @@ -89,7 +91,7 @@ public static IReadOnlyList ApplyRecentApprovalsQuery( } /// - /// Applies a pending approval view query to already materialized requests. + /// Applies pending approval view query to already materialized requests. /// /// The materialized requests. /// The approval query to evaluate. @@ -109,7 +111,7 @@ public static IReadOnlyList ApplyPendingApprovalViewQuery( } /// - /// Applies a recent decision query to already materialized requests. + /// Applies recent decision query to already materialized requests. /// /// The materialized requests. /// The decision query to evaluate. diff --git a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestViewProjector.cs b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestViewProjector.cs index e9b2fd4..4e0e883 100644 --- a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestViewProjector.cs +++ b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestViewProjector.cs @@ -1,4 +1,5 @@ -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; @@ -8,6 +9,11 @@ namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization /// internal static class RedisMutationRequestViewProjector { + /// + /// Projects governed requests into approval-centric query views. + /// + /// Requests to project. + /// Approval views produced from request approval requirements. public static IEnumerable ToApprovalViews(IEnumerable requests) { ArgumentNullException.ThrowIfNull(requests); @@ -19,6 +25,11 @@ public static IEnumerable ToApprovalViews(IEnumerable + /// Projects governed requests into decision-centric query views. + /// + /// Requests to project. + /// Decision views produced from request history entries. public static IEnumerable ToDecisionViews(IEnumerable requests) { ArgumentNullException.ThrowIfNull(requests); diff --git a/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryDocumentLoader.cs b/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryDocumentLoader.cs index 46b3a5b..8108e23 100644 --- a/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryDocumentLoader.cs +++ b/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryDocumentLoader.cs @@ -1,5 +1,5 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Redis.Storage.Candidates; using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Reading; diff --git a/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryReader.cs b/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryReader.cs index a786a84..346cdb0 100644 --- a/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryReader.cs +++ b/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryReader.cs @@ -1,5 +1,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; @@ -120,7 +122,7 @@ public async Task> GetRecentApprovalsAsync( int? take = null, CancellationToken cancellationToken = default) { - var effectiveQuery = query ?? MutationRequestQuery.RecentApprovals(); + var effectiveQuery = query ?? MutationRequestQueries.RecentApprovals(); var requests = await _documentLoader.LoadByRequestQueryAsync(effectiveQuery, cancellationToken).ConfigureAwait(false); return RedisMutationRequestQueryMaterializer.ApplyRecentApprovalsQuery(requests, effectiveQuery, take); } diff --git a/src/Redis/Storage/RedisMutationRequestStore.cs b/src/Redis/Storage/RedisMutationRequestStore.cs index 7d851f8..8d2a15a 100644 --- a/src/Redis/Storage/RedisMutationRequestStore.cs +++ b/src/Redis/Storage/RedisMutationRequestStore.cs @@ -1,6 +1,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Storage; using ModularityKit.Mutator.Governance.Redis.Storage.Persistence; @@ -10,7 +12,7 @@ namespace ModularityKit.Mutator.Governance.Redis.Storage; /// -/// Redis-backed implementation of governed mutation request storage and query access. +/// Implementation of governed mutation request storage and query access. /// public sealed class RedisMutationRequestStore : IMutationRequestStore, IMutationRequestQueryStore { @@ -26,7 +28,7 @@ internal RedisMutationRequestStore( } /// - /// Creates a governed mutation request in Redis storage. + /// Creates governed mutation request in storage. /// /// The request to create. /// The cancellation token. @@ -37,7 +39,7 @@ public Task Create( => _persistence.Create(request, cancellationToken); /// - /// Attempts to store a governed mutation request update using optimistic concurrency. + /// Attempts to store governed mutation request update using optimistic concurrency. /// /// The request to store. /// The expected current revision. @@ -50,7 +52,7 @@ public Task Create( => _persistence.TryStore(request, expectedRevision, cancellationToken); /// - /// Reads a governed mutation request by identifier. + /// Reads governed mutation request by identifier. /// /// The request identifier. /// The cancellation token. @@ -61,7 +63,7 @@ public Task Create( => _persistence.Get(requestId, cancellationToken); /// - /// Reads governed mutation requests for a specific state identifier. + /// Reads governed mutation requests for specific state identifier. /// /// The state identifier. /// The cancellation token. @@ -83,7 +85,7 @@ public Task> GetPending( => _queryReader.GetPending(reason, cancellationToken); /// - /// Reads pending governed mutation requests for a specific state identifier. + /// Reads pending governed mutation requests for specific state identifier. /// /// The state identifier. /// The optional pending reason. From 51648bf73f50975e3fa3f2a470d0e3669a373ec4 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:51:33 +0200 Subject: [PATCH 11/15] test(redis): verify intent metadata roundtrip --- .../Converters/RedisMutationRequestSerializerTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs index f62620f..48bc73b 100644 --- a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs @@ -73,6 +73,7 @@ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime() Assert.Equal(request.Intent.Category, roundtrip.Intent.Category); Assert.Contains("security", roundtrip.Intent.Tags); Assert.Equal(BlastRadiusScope.Module, roundtrip.Intent.EstimatedBlastRadius?.Scope); + Assert.Equal("platform", roundtrip.Intent.Metadata["risk-owner"]); Assert.Equal("security", roundtrip.Metadata["team"]); Assert.Single(roundtrip.Requirements); Assert.Single(roundtrip.ApprovalRequirements); From e73346ba215ee08cc831ccddc248332663103d82 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:51:41 +0200 Subject: [PATCH 12/15] refactor(redis-example): use query namespaces and decision helpers --- .../Scenarios/GovernanceRedisQueriesScenario.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs index f5601e6..13a24f4 100644 --- a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs +++ b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs @@ -4,7 +4,9 @@ using ModularityKit.Mutator.Abstractions.Policies; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; @@ -152,22 +154,22 @@ private static MutationRequest CreateExecutedRequest( ExecutedAt = executedAt, Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, MutationContext.Service("governance-runtime", "Submitted")) with { Timestamp = executedAt.AddMinutes(-15) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, MutationContext.Service("governance-runtime", "Approved")) with { Timestamp = executedAt.AddMinutes(-5) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Executed, MutationContext.Service("governance-runtime", "Executed")) with { From f43dc4bffc98e9388befafe04bab0ec261483c4c Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:53:33 +0200 Subject: [PATCH 13/15] test(governance): verify governed execution metadata propagation --- .../GovernanceExecutionManagerTests.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs index ea78b9d..accecab 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs @@ -44,10 +44,20 @@ public async Task ExecuteApproved_executes_request_persists_resulting_version_an { OperationName = "GrantRole", Category = "Security", - Description = "Grant elevated access" + Description = "Grant elevated access", + Tags = new HashSet { "security", "incident" }, + EstimatedBlastRadius = BlastRadius.Module, + Metadata = new Dictionary + { + ["risk-owner"] = "platform" + } }, context: MutationContext.User("requester", "Requester", "Need access"), - expectedStateVersion: "v10")); + expectedStateVersion: "v10", + metadata: new Dictionary + { + ["ticket"] = "INC-42" + })); var mutation = new PromoteRoleMutation( MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), nextVersion: "v11"); @@ -78,6 +88,11 @@ public async Task ExecuteApproved_executes_request_persists_resulting_version_an Assert.Single(history.Entries); Assert.Equal(request.RequestId, auditEntries[0].Context.Metadata["GovernanceRequestId"]); Assert.Equal(request.RequestId, history.Entries[0].Context.Metadata["GovernanceRequestId"]); + Assert.Contains("security", auditEntries[0].MutationIntent.Tags); + Assert.Equal(BlastRadiusScope.Module, auditEntries[0].MutationIntent.EstimatedBlastRadius?.Scope); + Assert.Equal("platform", auditEntries[0].MutationIntent.Metadata["risk-owner"]); + Assert.Equal("platform", history.Entries[0].Intent.Metadata["risk-owner"]); + Assert.Equal("INC-42", ((IReadOnlyDictionary)auditEntries[0].Context.Metadata["GovernanceRequestMetadata"])["ticket"]); } [Fact] From 58354161d5ea54d40966c2b19881707be16ad93d Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:53:42 +0200 Subject: [PATCH 14/15] test(governance): cover grouped query filters and metadata scopes --- .../Queries/MutationRequestQueryStoreTests.cs | 111 +++++++++++++++--- 1 file changed, 95 insertions(+), 16 deletions(-) diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs index ce202b0..c334e6b 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs @@ -3,7 +3,10 @@ using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; using ModularityKit.Mutator.Abstractions.Policies; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; @@ -27,7 +30,8 @@ public async Task QueryAsync_filters_requests_by_governance_dimensions() actorName: "Alice", category: "Security", tags: new HashSet { "security", "urgent" }, - metadata: new Dictionary { ["team"] = "platform" }, + intentMetadata: new Dictionary { ["team"] = "platform" }, + requestMetadata: new Dictionary { ["team"] = "platform" }, blastRadius: BlastRadius.Module, createdAt: new DateTimeOffset(2026, 6, 1, 10, 0, 0, TimeSpan.Zero), updatedAt: new DateTimeOffset(2026, 6, 1, 11, 0, 0, TimeSpan.Zero), @@ -55,7 +59,8 @@ await store.Create(CreateGovernedRequest( actorName: "Bob", category: "Billing", tags: new HashSet { "billing" }, - metadata: new Dictionary { ["team"] = "finance" }, + intentMetadata: new Dictionary { ["team"] = "finance" }, + requestMetadata: new Dictionary { ["team"] = "finance" }, blastRadius: BlastRadius.System, createdAt: new DateTimeOffset(2026, 6, 2, 10, 0, 0, TimeSpan.Zero), updatedAt: new DateTimeOffset(2026, 6, 2, 11, 0, 0, TimeSpan.Zero), @@ -73,16 +78,32 @@ await store.Create(CreateGovernedRequest( var results = await store.QueryAsync(new MutationRequestQuery { - Statuses = new HashSet { MutationRequestStatus.Pending }, - PendingReasons = new HashSet { PendingMutationReason.Approval }, - ActorIds = new HashSet { "alice" }, - Categories = new HashSet { "Security" }, - Tags = new HashSet { "security", "urgent" }, - TagMatchMode = MutationRequestTagMatchMode.All, - Metadata = new Dictionary { ["team"] = "platform" }, - MinimumBlastRadiusScope = BlastRadiusScope.Module, - CreatedFrom = new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero), - CreatedTo = new DateTimeOffset(2026, 6, 1, 23, 59, 59, TimeSpan.Zero) + Lifecycle = new MutationRequestLifecycleFilter + { + Statuses = new HashSet { MutationRequestStatus.Pending }, + PendingReasons = new HashSet { PendingMutationReason.Approval } + }, + Actor = new MutationRequestActorFilter + { + ActorIds = new HashSet { "alice" } + }, + Intent = new MutationRequestIntentFilter + { + Categories = new HashSet { "Security" }, + Tags = new HashSet { "security", "urgent" }, + TagMatchMode = MutationRequestTagMatchMode.All, + Metadata = new Dictionary { ["team"] = "platform" }, + MinimumBlastRadiusScope = BlastRadiusScope.Module + }, + Metadata = new MutationRequestMetadataFilter + { + Values = new Dictionary { ["team"] = "platform" } + }, + TimeRange = new MutationRequestTimeRangeFilter + { + CreatedFrom = new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero), + CreatedTo = new DateTimeOffset(2026, 6, 1, 23, 59, 59, TimeSpan.Zero) + } }); Assert.Single(results); @@ -111,6 +132,63 @@ await store.Create(CreateSimpleRequest( Assert.Equal(pending.RequestId, results[0].RequestId); } + [Fact] + public async Task QueryAsync_can_filter_by_intent_metadata_independently_from_request_metadata() + { + var store = new InMemoryMutationRequestStore(); + + await store.Create(CreateGovernedRequest( + requestId: "req-platform", + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + actorId: "alice", + actorName: "Alice", + category: "Security", + tags: new HashSet { "security" }, + intentMetadata: new Dictionary { ["risk-owner"] = "platform" }, + requestMetadata: new Dictionary { ["ticket"] = "INC-42" }, + blastRadius: BlastRadius.Module, + createdAt: new DateTimeOffset(2026, 6, 1, 10, 0, 0, TimeSpan.Zero), + updatedAt: new DateTimeOffset(2026, 6, 1, 11, 0, 0, TimeSpan.Zero), + status: MutationRequestStatus.Pending, + pendingReason: PendingMutationReason.Approval, + decisions: [])); + + await store.Create(CreateGovernedRequest( + requestId: "req-finance", + stateId: "tenant-42:quota", + stateType: "QuotaState", + mutationType: "IncreaseQuotaMutation", + actorId: "bob", + actorName: "Bob", + category: "Billing", + tags: new HashSet { "billing" }, + intentMetadata: new Dictionary { ["risk-owner"] = "finance" }, + requestMetadata: new Dictionary { ["ticket"] = "FIN-9" }, + blastRadius: BlastRadius.Single, + createdAt: new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero), + updatedAt: new DateTimeOffset(2026, 6, 1, 12, 30, 0, TimeSpan.Zero), + status: MutationRequestStatus.Approved, + pendingReason: null, + decisions: [])); + + var results = await store.QueryAsync(new MutationRequestQuery + { + Intent = new MutationRequestIntentFilter + { + Metadata = new Dictionary { ["risk-owner"] = "platform" } + }, + Metadata = new MutationRequestMetadataFilter + { + Values = new Dictionary { ["ticket"] = "INC-42" } + } + }); + + Assert.Single(results); + Assert.Equal("req-platform", results[0].RequestId); + } + [Fact] public async Task GetPendingApprovalQueueAsync_and_GetRecentApprovalsAsync_return_approval_oriented_views() { @@ -389,7 +467,8 @@ private static MutationRequest CreateGovernedRequest( string actorName, string category, IReadOnlySet tags, - IReadOnlyDictionary metadata, + IReadOnlyDictionary intentMetadata, + IReadOnlyDictionary requestMetadata, BlastRadius blastRadius, DateTimeOffset createdAt, DateTimeOffset updatedAt, @@ -407,7 +486,7 @@ private static MutationRequest CreateGovernedRequest( OperationName = mutationType, Category = category, Tags = tags, - Metadata = metadata, + Metadata = intentMetadata, EstimatedBlastRadius = blastRadius }, Context = MutationContext.User(actorId, actorName, "Query test"), @@ -416,6 +495,6 @@ private static MutationRequest CreateGovernedRequest( CreatedAt = createdAt, UpdatedAt = updatedAt, Decisions = decisions, - Metadata = metadata + Metadata = requestMetadata }; } From ddf843e9a0b08a6a8f302e476ad957e46e5c9676 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 00:53:53 +0200 Subject: [PATCH 15/15] test(governance): add decision helper factory coverage --- .../Requests/MutationRequestDecisionTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestDecisionTests.cs diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestDecisionTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestDecisionTests.cs new file mode 100644 index 0000000..3cc182a --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestDecisionTests.cs @@ -0,0 +1,43 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Requests; + +public sealed class MutationRequestDecisionTests +{ + [Fact] + public void Lifecycle_factory_wraps_lifecycle_type() + { + var decision = MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, + MutationContext.User("alice", "Alice", "Approved"), + reason: "Approved by operator"); + + Assert.Equal(MutationRequestDecisionCategory.Lifecycle, decision.Type.Category); + Assert.Equal(nameof(MutationRequestLifecycleDecisionType.Approved), decision.Type.Code); + Assert.Equal("Approved by operator", decision.Reason); + } + + [Fact] + public void Approval_factory_wraps_approval_type() + { + var decision = MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.QuorumSatisfied, + MutationContext.User("bob", "Bob", "Quorum satisfied")); + + Assert.Equal(MutationRequestDecisionCategory.Approval, decision.Type.Category); + Assert.Equal(nameof(MutationRequestApprovalDecisionType.QuorumSatisfied), decision.Type.Code); + } + + [Fact] + public void VersionResolution_factory_wraps_version_resolution_type() + { + var decision = MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.RejectedAsStale, + MutationContext.User("carol", "Carol", "Stale")); + + Assert.Equal(MutationRequestDecisionCategory.VersionResolution, decision.Type.Category); + Assert.Equal(nameof(MutationRequestVersionResolutionDecisionType.RejectedAsStale), decision.Type.Code); + } +}