From 22d8ea2aeda37f33cb23f9c2f29aeef76b3d7deb Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 18:08:43 +0200 Subject: [PATCH 01/17] Feat: Add governed execution abstraction models --- .../Compensation/GovernedCompensationKind.cs | 17 +++++++ .../Compensation/GovernedCompensationPlan.cs | 43 ++++++++++++++++++ .../GovernedCompensationTrigger.cs | 22 ++++++++++ .../Execution/Model/GovernedExecutionKind.cs | 17 +++++++ .../Model/GovernedExecutionResult.cs | 13 +++++- .../Model/Links/GovernedExecutionLink.cs | 44 +++++++++++++++++++ .../Model/Links/GovernedExecutionLinkType.cs | 17 +++++++ 7 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 src/Governance/Abstractions/Execution/Model/Compensation/GovernedCompensationKind.cs create mode 100644 src/Governance/Abstractions/Execution/Model/Compensation/GovernedCompensationPlan.cs create mode 100644 src/Governance/Abstractions/Execution/Model/Compensation/GovernedCompensationTrigger.cs create mode 100644 src/Governance/Abstractions/Execution/Model/GovernedExecutionKind.cs create mode 100644 src/Governance/Abstractions/Execution/Model/Links/GovernedExecutionLink.cs create mode 100644 src/Governance/Abstractions/Execution/Model/Links/GovernedExecutionLinkType.cs diff --git a/src/Governance/Abstractions/Execution/Model/Compensation/GovernedCompensationKind.cs b/src/Governance/Abstractions/Execution/Model/Compensation/GovernedCompensationKind.cs new file mode 100644 index 0000000..bf83ebb --- /dev/null +++ b/src/Governance/Abstractions/Execution/Model/Compensation/GovernedCompensationKind.cs @@ -0,0 +1,17 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation; + +/// +/// Distinguishes rollback-style restoration from forward corrective compensation. +/// +public enum GovernedCompensationKind +{ + /// + /// Attempts to restore the prior state or its equivalent. + /// + Rollback = 0, + + /// + /// Applies a forward corrective action instead of restoring prior state. + /// + ForwardCorrection = 1 +} diff --git a/src/Governance/Abstractions/Execution/Model/Compensation/GovernedCompensationPlan.cs b/src/Governance/Abstractions/Execution/Model/Compensation/GovernedCompensationPlan.cs new file mode 100644 index 0000000..ee85145 --- /dev/null +++ b/src/Governance/Abstractions/Execution/Model/Compensation/GovernedCompensationPlan.cs @@ -0,0 +1,43 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation; + +/// +/// Describes how a governed request compensates for a prior execution. +/// +public sealed record GovernedCompensationPlan +{ + /// + /// Request identifier of the original execution being compensated. + /// + public string OriginalRequestId { get; init; } = string.Empty; + + /// + /// Compensation style to apply. + /// + public GovernedCompensationKind Kind { get; init; } = GovernedCompensationKind.Rollback; + + /// + /// Trigger that initiated the compensation. + /// + public GovernedCompensationTrigger Trigger { get; init; } = GovernedCompensationTrigger.OperatorRollback; + + /// + /// Optional batch identifier for batch-oriented compensation plans. + /// + public string? BatchId { get; init; } + + /// + /// Optional identifiers of related requests in the same compensation plan. + /// + public IReadOnlyList RelatedRequestIds { get; init; } = []; + + /// + /// Optional human-readable rationale for the compensation. + /// + public string? Reason { get; init; } + + internal void EnsureValid() + { + if (string.IsNullOrWhiteSpace(OriginalRequestId)) + throw new InvalidOperationException("Compensation requests require an original request identifier."); + } +} diff --git a/src/Governance/Abstractions/Execution/Model/Compensation/GovernedCompensationTrigger.cs b/src/Governance/Abstractions/Execution/Model/Compensation/GovernedCompensationTrigger.cs new file mode 100644 index 0000000..7050274 --- /dev/null +++ b/src/Governance/Abstractions/Execution/Model/Compensation/GovernedCompensationTrigger.cs @@ -0,0 +1,22 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation; + +/// +/// Describes what initiated a governed compensation plan. +/// +public enum GovernedCompensationTrigger +{ + /// + /// A human operator explicitly initiated rollback or correction. + /// + OperatorRollback = 0, + + /// + /// A batch workflow initiated compensation after one or more failures. + /// + BatchFailure = 1, + + /// + /// A failed execution path initiated a compensating follow-up action. + /// + FailedExecution = 2 +} diff --git a/src/Governance/Abstractions/Execution/Model/GovernedExecutionKind.cs b/src/Governance/Abstractions/Execution/Model/GovernedExecutionKind.cs new file mode 100644 index 0000000..25c8c2e --- /dev/null +++ b/src/Governance/Abstractions/Execution/Model/GovernedExecutionKind.cs @@ -0,0 +1,17 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Execution.Model; + +/// +/// Classifies governed execution as a primary mutation or compensating execution. +/// +public enum GovernedExecutionKind +{ + /// + /// Standard governed execution for the originally requested mutation. + /// + Standard = 0, + + /// + /// Governed execution intended to compensate for a prior execution. + /// + Compensation = 1 +} diff --git a/src/Governance/Abstractions/Execution/Model/GovernedExecutionResult.cs b/src/Governance/Abstractions/Execution/Model/GovernedExecutionResult.cs index 7103f77..450f040 100644 --- a/src/Governance/Abstractions/Execution/Model/GovernedExecutionResult.cs +++ b/src/Governance/Abstractions/Execution/Model/GovernedExecutionResult.cs @@ -1,11 +1,12 @@ using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; namespace ModularityKit.Mutator.Governance.Abstractions.Execution.Model; /// -/// Captures the outcome of executing a governed mutation request. +/// Captures the outcome of executing governed mutation request. /// public sealed record GovernedExecutionResult { @@ -29,6 +30,16 @@ public sealed record GovernedExecutionResult /// public bool WasExecuted { get; init; } + /// + /// Classifies the execution path represented by this result. + /// + public GovernedExecutionKind ExecutionKind { get; init; } = GovernedExecutionKind.Standard; + + /// + /// Compensation plan attached to the execution when the request is compensating. + /// + public GovernedCompensationPlan? Compensation { get; init; } + /// /// Resulting state version recorded after a successful execution. /// diff --git a/src/Governance/Abstractions/Execution/Model/Links/GovernedExecutionLink.cs b/src/Governance/Abstractions/Execution/Model/Links/GovernedExecutionLink.cs new file mode 100644 index 0000000..0c4f34e --- /dev/null +++ b/src/Governance/Abstractions/Execution/Model/Links/GovernedExecutionLink.cs @@ -0,0 +1,44 @@ +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation; + +namespace ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Links; + +/// +/// Represents an explicit relation between governed execution records. +/// +public sealed record GovernedExecutionLink +{ + /// + /// Linked request identifier. + /// + public string RequestId { get; init; } = string.Empty; + + /// + /// Relationship to the linked request. + /// + public GovernedExecutionLinkType Type { get; init; } + + /// + /// Execution kind of the linked request. + /// + public GovernedExecutionKind ExecutionKind { get; init; } = GovernedExecutionKind.Standard; + + /// + /// Compensation style associated with the link when applicable. + /// + public GovernedCompensationKind? CompensationKind { get; init; } + + /// + /// Trigger that led to the compensating execution when applicable. + /// + public GovernedCompensationTrigger? Trigger { get; init; } + + /// + /// Optional batch identifier associated with the compensation plan. + /// + public string? BatchId { get; init; } + + /// + /// Time when the relation was recorded. + /// + public DateTimeOffset LinkedAt { get; init; } = DateTimeOffset.UtcNow; +} diff --git a/src/Governance/Abstractions/Execution/Model/Links/GovernedExecutionLinkType.cs b/src/Governance/Abstractions/Execution/Model/Links/GovernedExecutionLinkType.cs new file mode 100644 index 0000000..2c3f8c7 --- /dev/null +++ b/src/Governance/Abstractions/Execution/Model/Links/GovernedExecutionLinkType.cs @@ -0,0 +1,17 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Links; + +/// +/// Describes how one governed request relates to another. +/// +public enum GovernedExecutionLinkType +{ + /// + /// The current request compensates for the linked request. + /// + Compensates = 0, + + /// + /// The current request was compensated by the linked request. + /// + CompensatedBy = 1 +} From 32e6468a906ca57d5145b6225751d75fab3d985e Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 18:25:29 +0200 Subject: [PATCH 02/17] Refine governance request abstractions --- .../Decisions/MutationRequestDecision.cs | 28 +++- .../MutationRequestLifecycleDecisionType.cs | 9 +- .../CompensationMutationRequestFactory.cs | 132 ++++++++++++++++++ .../Factory/MutationRequestFactory.cs | 73 +++++++++- .../Model/GovernedExecutionDetails.cs | 26 ++++ .../Requests/Model/MutationRequest.cs | 21 ++- .../Model/MutationRequestVersioningDetails.cs | 22 +++ 7 files changed, 289 insertions(+), 22 deletions(-) create mode 100644 src/Governance/Abstractions/Requests/Factory/CompensationMutationRequestFactory.cs create mode 100644 src/Governance/Abstractions/Requests/Model/GovernedExecutionDetails.cs create mode 100644 src/Governance/Abstractions/Requests/Model/MutationRequestVersioningDetails.cs diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs index fe238e3..fde1813 100644 --- a/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs @@ -3,7 +3,7 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; /// -/// Captures a single decision or lifecycle transition applied to a mutation request. +/// Captures single decision or lifecycle transition applied to mutation request. /// public sealed record MutationRequestDecision { @@ -33,8 +33,13 @@ public sealed record MutationRequestDecision public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); /// - /// Creates a lifecycle decision entry. + /// Creates lifecycle decision entry. /// + /// Lifecycle decision type. + /// Actor or system context that records the decision. + /// Optional human-readable explanation for the decision. + /// Optional governance metadata attached to the decision. + /// A lifecycle decision entry. public static MutationRequestDecision Lifecycle( MutationRequestLifecycleDecisionType type, MutationContext context, @@ -49,6 +54,11 @@ public static MutationRequestDecision Lifecycle( /// /// Creates an approval decision entry. /// + /// Approval decision type. + /// Actor or system context that records the decision. + /// Optional human-readable explanation for the decision. + /// Optional governance metadata attached to the decision. + /// An approval decision entry. public static MutationRequestDecision Approval( MutationRequestApprovalDecisionType type, MutationContext context, @@ -61,8 +71,13 @@ public static MutationRequestDecision Approval( metadata); /// - /// Creates a version-resolution decision entry. + /// Creates version resolution decision entry. /// + /// Version-resolution decision type. + /// Actor or system context that records the decision. + /// Optional human-readable explanation for the decision. + /// Optional governance metadata attached to the decision. + /// A version-resolution decision entry. public static MutationRequestDecision VersionResolution( MutationRequestVersionResolutionDecisionType type, MutationContext context, @@ -75,8 +90,13 @@ public static MutationRequestDecision VersionResolution( metadata); /// - /// Creates a new request decision entry. + /// Creates new request decision entry. /// + /// Decision type wrapper including category and stable code. + /// Actor or system context that records the decision. + /// Optional human-readable explanation for the decision. + /// Optional governance metadata attached to the decision. + /// A new request decision entry. public static MutationRequestDecision Create( MutationRequestDecisionType type, MutationContext context, diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs index 337f7c4..1398549 100644 --- a/src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestLifecycleDecisionType.cs @@ -1,7 +1,7 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; /// -/// Represents high-level lifecycle decisions taken against a mutation request. +/// Represents lifecycle decisions taken against mutation request. /// public enum MutationRequestLifecycleDecisionType { @@ -36,5 +36,10 @@ public enum MutationRequestLifecycleDecisionType /// /// The request executed successfully. /// - Executed = 7 + Executed = 7, + + /// + /// A successful compensation execution was recorded against this request. + /// + Compensated = 8 } diff --git a/src/Governance/Abstractions/Requests/Factory/CompensationMutationRequestFactory.cs b/src/Governance/Abstractions/Requests/Factory/CompensationMutationRequestFactory.cs new file mode 100644 index 0000000..2aa255c --- /dev/null +++ b/src/Governance/Abstractions/Requests/Factory/CompensationMutationRequestFactory.cs @@ -0,0 +1,132 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Links; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; + +/// +/// Creates governed mutation requests for compensation flows. +/// +public static class CompensationMutationRequestFactory +{ + /// + /// Creates an immediately approved compensation request using type inference for the target state and mutation. + /// + /// The target state type. + /// The compensation mutation type. + /// Stable identifier of the target state. + /// Intent associated with the compensating mutation. + /// Request context describing who initiated the compensation and why. + /// Compensation plan describing the original execution and recovery semantics. + /// Optional expected state version captured before compensation execution. + /// Optional governance metadata carried by the request. + /// An approved governed compensation request. + public static MutationRequest Approved( + string stateId, + MutationIntent intent, + MutationContext context, + GovernedCompensationPlan compensation, + string? expectedStateVersion = null, + IReadOnlyDictionary? metadata = null) + where TMutation : IMutation + => Approved( + stateId, + typeof(TState).Name, + typeof(TMutation).Name, + intent, + context, + compensation, + expectedStateVersion, + metadata); + + /// + /// Creates an immediately approved compensation request. + /// + /// Stable identifier of the target state. + /// Logical state type name. + /// Compensation mutation type name. + /// Intent associated with the compensating mutation. + /// Request context describing who initiated the compensation and why. + /// Compensation plan describing the original execution and recovery semantics. + /// Optional expected state version captured before compensation execution. + /// Optional governance metadata carried by the request. + /// An approved governed compensation request. + public static MutationRequest Approved( + string stateId, + string stateType, + string mutationType, + MutationIntent intent, + MutationContext context, + GovernedCompensationPlan compensation, + string? expectedStateVersion = null, + IReadOnlyDictionary? metadata = null) + { + ArgumentNullException.ThrowIfNull(compensation); + compensation.EnsureValid(); + + var request = MutationRequestFactory.Approved( + stateId, + stateType, + mutationType, + intent, + context, + expectedStateVersion, + metadata); + + return request with + { + Execution = new GovernedExecutionDetails + { + Kind = GovernedExecutionKind.Compensation, + Compensation = compensation, + RelatedExecutions = + [ + new GovernedExecutionLink + { + RequestId = compensation.OriginalRequestId, + Type = GovernedExecutionLinkType.Compensates, + ExecutionKind = GovernedExecutionKind.Standard, + CompensationKind = compensation.Kind, + Trigger = compensation.Trigger, + BatchId = compensation.BatchId + } + ] + }, + Decisions = + [ + .. request.Decisions.Take(request.Decisions.Count - 1), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, + context, + reason: $"Compensation request approved at submission time for original request '{compensation.OriginalRequestId}'.", + metadata: CreateCompensationMetadata(compensation)) + ] + }; + } + + private static IReadOnlyDictionary CreateCompensationMetadata(GovernedCompensationPlan compensation) + { + var metadata = new Dictionary + { + ["OriginalRequestId"] = compensation.OriginalRequestId, + ["CompensationKind"] = compensation.Kind.ToString(), + ["CompensationTrigger"] = compensation.Trigger.ToString() + }; + + if (!string.IsNullOrWhiteSpace(compensation.BatchId)) + metadata["BatchId"] = compensation.BatchId; + + if (compensation.RelatedRequestIds.Count > 0) + metadata["RelatedRequestIds"] = compensation.RelatedRequestIds; + + if (!string.IsNullOrWhiteSpace(compensation.Reason)) + metadata["CompensationReason"] = compensation.Reason; + + return metadata; + } +} diff --git a/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs b/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs index 9b692ff..4062463 100644 --- a/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs +++ b/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs @@ -17,6 +17,17 @@ public static class MutationRequestFactory /// /// Creates a request that should enter the pending lifecycle using type inference for the target state and mutation. /// + /// The target state type. + /// The mutation type. + /// Stable identifier of the target state. + /// Intent associated with the requested mutation. + /// Request context describing who submitted the mutation and why. + /// Lifecycle reason that keeps the request pending. + /// Optional policy requirements attached to the request. + /// Optional expected state version captured at submission time. + /// Optional expiration time for the pending request. + /// Optional governance metadata carried by the request. + /// A pending governed mutation request. public static MutationRequest Pending( string stateId, MutationIntent intent, @@ -42,6 +53,17 @@ public static MutationRequest Pending( /// /// Creates a request that should enter the pending lifecycle. /// + /// Stable identifier of the target state. + /// Logical state type name. + /// Mutation type name. + /// Intent associated with the requested mutation. + /// Request context describing who submitted the mutation and why. + /// Lifecycle reason that keeps the request pending. + /// Optional policy requirements attached to the request. + /// Optional expected state version captured at submission time. + /// Optional expiration time for the pending request. + /// Optional governance metadata carried by the request. + /// A pending governed mutation request. public static MutationRequest Pending( string stateId, string stateType, @@ -64,7 +86,10 @@ public static MutationRequest Pending( Status = MutationRequestStatus.Pending, PendingReason = pendingReason, Requirements = requirements ?? [], - ExpectedStateVersion = expectedStateVersion, + Versioning = new MutationRequestVersioningDetails + { + ExpectedStateVersion = expectedStateVersion + }, ExpiresAt = expiresAt, Metadata = metadata ?? new Dictionary(), Decisions = @@ -84,6 +109,16 @@ public static MutationRequest Pending( /// /// Creates a request that enters pending approval using type inference for the target state and mutation. /// + /// The target state type. + /// The mutation type. + /// Stable identifier of the target state. + /// Intent associated with the requested mutation. + /// Request context describing who submitted the mutation and why. + /// Policy requirements that will be translated into approval requirements. + /// Optional expected state version captured at submission time. + /// Optional expiration time for the pending request. + /// Optional governance metadata carried by the request. + /// A governed mutation request pending approval. public static MutationRequest PendingApproval( string stateId, MutationIntent intent, @@ -107,6 +142,16 @@ public static MutationRequest PendingApproval( /// /// Creates a request that enters pending approval with concrete request-level approval requirements. /// + /// Stable identifier of the target state. + /// Logical state type name. + /// Mutation type name. + /// Intent associated with the requested mutation. + /// Request context describing who submitted the mutation and why. + /// Policy requirements that will be translated into approval requirements. + /// Optional expected state version captured at submission time. + /// Optional expiration time for the pending request. + /// Optional governance metadata carried by the request. + /// A governed mutation request pending approval. public static MutationRequest PendingApproval( string stateId, string stateType, @@ -135,7 +180,10 @@ public static MutationRequest PendingApproval( PendingReason = PendingMutationReason.Approval, Requirements = requirements, ApprovalRequirements = approvalRequirements, - ExpectedStateVersion = expectedStateVersion, + Versioning = new MutationRequestVersioningDetails + { + ExpectedStateVersion = expectedStateVersion + }, ExpiresAt = expiresAt, Metadata = metadata ?? new Dictionary(), Decisions = @@ -163,6 +211,14 @@ public static MutationRequest PendingApproval( /// /// Creates a request that is immediately approved for execution using type inference for the target state and mutation. /// + /// The target state type. + /// The mutation type. + /// Stable identifier of the target state. + /// Intent associated with the requested mutation. + /// Request context describing who submitted the mutation and why. + /// Optional expected state version captured at submission time. + /// Optional governance metadata carried by the request. + /// An approved governed mutation request. public static MutationRequest Approved( string stateId, MutationIntent intent, @@ -182,6 +238,14 @@ public static MutationRequest Approved( /// /// Creates a request that is immediately approved for execution. /// + /// Stable identifier of the target state. + /// Logical state type name. + /// Mutation type name. + /// Intent associated with the requested mutation. + /// Request context describing who submitted the mutation and why. + /// Optional expected state version captured at submission time. + /// Optional governance metadata carried by the request. + /// An approved governed mutation request. public static MutationRequest Approved( string stateId, string stateType, @@ -199,7 +263,10 @@ public static MutationRequest Approved( Intent = intent, Context = context, Status = MutationRequestStatus.Approved, - ExpectedStateVersion = expectedStateVersion, + Versioning = new MutationRequestVersioningDetails + { + ExpectedStateVersion = expectedStateVersion + }, Metadata = metadata ?? new Dictionary(), Decisions = [ diff --git a/src/Governance/Abstractions/Requests/Model/GovernedExecutionDetails.cs b/src/Governance/Abstractions/Requests/Model/GovernedExecutionDetails.cs new file mode 100644 index 0000000..340d99d --- /dev/null +++ b/src/Governance/Abstractions/Requests/Model/GovernedExecutionDetails.cs @@ -0,0 +1,26 @@ +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Links; + +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +/// +/// Groups governed execution-specific details carried by mutation request. +/// +public sealed record GovernedExecutionDetails +{ + /// + /// Classifies this request as standard governed execution or compensating execution. + /// + public GovernedExecutionKind Kind { get; init; } = GovernedExecutionKind.Standard; + + /// + /// Compensation plan carried by this request when it compensates for prior execution. + /// + public GovernedCompensationPlan? Compensation { get; init; } + + /// + /// Explicit links to related governed execution records. + /// + public IReadOnlyList RelatedExecutions { get; init; } = []; +} diff --git a/src/Governance/Abstractions/Requests/Model/MutationRequest.cs b/src/Governance/Abstractions/Requests/Model/MutationRequest.cs index 6b23bb7..6d63ffa 100644 --- a/src/Governance/Abstractions/Requests/Model/MutationRequest.cs +++ b/src/Governance/Abstractions/Requests/Model/MutationRequest.cs @@ -9,7 +9,7 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Model; /// -/// Represents a governed mutation request that may execute immediately or enter a pending lifecycle. +/// Represents governed mutation request that may execute immediately or enter a pending lifecycle. /// public sealed record MutationRequest { @@ -43,6 +43,11 @@ public sealed record MutationRequest /// public MutationContext Context { get; init; } = null!; + /// + /// Governed execution-specific details associated with this request. + /// + public GovernedExecutionDetails Execution { get; init; } = new(); + /// /// Current lifecycle status of the request. /// @@ -79,25 +84,15 @@ public sealed record MutationRequest public long Revision { get; init; } /// - /// Expected version or concurrency token for the target state. + /// Versioning and execution completion details associated with the request. /// - public string? ExpectedStateVersion { get; init; } - - /// - /// Resulting version of the target state after successful governed execution. - /// - public string? ResultingStateVersion { get; init; } + public MutationRequestVersioningDetails Versioning { get; init; } = new(); /// /// Optional expiration time for pending requests. /// public DateTimeOffset? ExpiresAt { get; init; } - /// - /// Timestamp when governed execution completed successfully. - /// - public DateTimeOffset? ExecutedAt { get; init; } - /// /// Timestamp when the request was first created. /// diff --git a/src/Governance/Abstractions/Requests/Model/MutationRequestVersioningDetails.cs b/src/Governance/Abstractions/Requests/Model/MutationRequestVersioningDetails.cs new file mode 100644 index 0000000..e13cb1f --- /dev/null +++ b/src/Governance/Abstractions/Requests/Model/MutationRequestVersioningDetails.cs @@ -0,0 +1,22 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +/// +/// Groups request-level versioning and execution completion details. +/// +public sealed record MutationRequestVersioningDetails +{ + /// + /// Expected version or concurrency token for the target state. + /// + public string? ExpectedStateVersion { get; init; } + + /// + /// Resulting version of the target state after successful governed execution. + /// + public string? ResultingStateVersion { get; init; } + + /// + /// Timestamp when governed execution completed successfully. + /// + public DateTimeOffset? ExecutedAt { get; init; } +} From e33ec7fb89d466b2110c4f136088b020ef0f3068 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 18:35:28 +0200 Subject: [PATCH 03/17] Refine governance execution runtime --- .../Execution/Mutation/GovernedMutation.cs | 37 +++++- .../GovernanceExecutionManager.cs | 117 ++++++++++++++++- .../GovernedExecutionDecisionFactory.cs | 122 ++++++++++++++++-- .../GovernedExecutionOutcomeHandler.cs | 72 ++++++++++- .../GovernedExecutionRequestPersistence.cs | 17 ++- 5 files changed, 343 insertions(+), 22 deletions(-) diff --git a/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs b/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs index 5a4ccb8..d16f882 100644 --- a/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs +++ b/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs @@ -2,6 +2,7 @@ using ModularityKit.Mutator.Abstractions.Engine; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Runtime.Execution.Mutation; @@ -15,6 +16,8 @@ internal sealed class GovernedMutation : IMutation private const string GovernanceRequestMetadataKey = "GovernanceRequestMetadata"; private const string GovernanceIntentMetadataKey = "GovernanceIntentMetadata"; private const string GovernanceEstimatedBlastRadiusMetadataKey = "GovernanceEstimatedBlastRadius"; + private const string GovernanceExecutionKindMetadataKey = "GovernanceExecutionKind"; + private const string GovernanceCompensationMetadataKey = "GovernanceCompensation"; private readonly IMutation _inner; @@ -58,7 +61,8 @@ private IReadOnlyDictionary MergeContextMetadata(MutationRequest { var metadata = new Dictionary(_inner.Context.Metadata) { - [GovernanceRequestIdMetadataKey] = request.RequestId + [GovernanceRequestIdMetadataKey] = request.RequestId, + [GovernanceExecutionKindMetadataKey] = request.Execution.Kind.ToString() }; if (request.Metadata.Count > 0) @@ -70,6 +74,9 @@ private IReadOnlyDictionary MergeContextMetadata(MutationRequest if (request.Intent.EstimatedBlastRadius is not null) metadata[GovernanceEstimatedBlastRadiusMetadataKey] = request.Intent.EstimatedBlastRadius; + if (request.Execution.Compensation is not null) + metadata[GovernanceCompensationMetadataKey] = CreateCompensationMetadata(request.Execution.Compensation); + return metadata; } @@ -77,12 +84,38 @@ private IReadOnlyDictionary MergeIntentMetadata(MutationRequest { var metadata = new Dictionary(request.Intent.Metadata) { - [GovernanceRequestIdMetadataKey] = request.RequestId + [GovernanceRequestIdMetadataKey] = request.RequestId, + [GovernanceExecutionKindMetadataKey] = request.Execution.Kind.ToString() }; if (_inner.Intent.Metadata.Count > 0) metadata["ExecutionIntentMetadata"] = _inner.Intent.Metadata; + if (request.Execution.Compensation is not null) + metadata[GovernanceCompensationMetadataKey] = CreateCompensationMetadata(request.Execution.Compensation); + + return metadata; + } + + private static IReadOnlyDictionary CreateCompensationMetadata( + GovernedCompensationPlan compensation) + { + var metadata = new Dictionary + { + ["OriginalRequestId"] = compensation.OriginalRequestId, + ["Kind"] = compensation.Kind.ToString(), + ["Trigger"] = compensation.Trigger.ToString() + }; + + if (!string.IsNullOrWhiteSpace(compensation.BatchId)) + metadata["BatchId"] = compensation.BatchId; + + if (compensation.RelatedRequestIds.Count > 0) + metadata["RelatedRequestIds"] = compensation.RelatedRequestIds; + + if (!string.IsNullOrWhiteSpace(compensation.Reason)) + metadata["Reason"] = compensation.Reason; + return metadata; } diff --git a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs index ee78c20..d756804 100644 --- a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs +++ b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs @@ -3,6 +3,9 @@ using ModularityKit.Mutator.Abstractions.Results; using ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts; using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Links; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Contracts; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; @@ -21,11 +24,25 @@ public sealed class GovernanceExecutionManager( IMutationRequestVersionResolutionManager resolutionManager, IMutationEngine mutationEngine) : IGovernanceExecutionManager { + private readonly IMutationRequestStore _requestStore = requestStore ?? throw new ArgumentNullException(nameof(requestStore)); private readonly IMutationRequestVersionResolutionManager _resolutionManager = resolutionManager ?? throw new ArgumentNullException(nameof(resolutionManager)); private readonly IMutationEngine _mutationEngine = mutationEngine ?? throw new ArgumentNullException(nameof(mutationEngine)); private readonly GovernedExecutionOutcomeHandler _outcomeHandler = new(new GovernedExecutionRequestPersistence(requestStore ?? throw new ArgumentNullException(nameof(requestStore)))); + /// + /// Executes approved governed mutation request against provided state snapshot. + /// + /// The state type handled by the governed mutation. + /// Stable identifier of the governed mutation request. + /// Mutation instance to execute after governance resolution succeeds. + /// Current state snapshot used for execution. + /// Current version or concurrency token of the state snapshot. + /// Delegate that resolves the resulting state version from the post-mutation state. + /// Context describing the governance actor or service performing execution. + /// Version-resolution strategy applied before execution. + /// Cancellation token. + /// The governed execution result, including persisted request state and optional mutation outcome. public async Task> ExecuteApproved( string requestId, IMutation mutation, @@ -74,11 +91,27 @@ public async Task> ExecuteApproved( throw; } - return await _outcomeHandler + var result = await _outcomeHandler .HandleMutationResult(execution, mutationResult, cancellationToken) .ConfigureAwait(false); + + if (result.WasExecuted) + await LinkCompensationExecution(result.Request, governanceContext, cancellationToken).ConfigureAwait(false); + + return result; } + /// + /// Executes approved governed mutation request against versioned state snapshot. + /// + /// The versioned state type handled by the governed mutation. + /// Stable identifier of the governed mutation request. + /// Mutation instance to execute after governance resolution succeeds. + /// Current versioned state snapshot used for execution. + /// Context describing the governance actor or service performing execution. + /// Version-resolution strategy applied before execution. + /// Cancellation token. + /// The governed execution result, including persisted request state and optional mutation outcome. public Task> ExecuteApproved( string requestId, IMutation mutation, @@ -97,6 +130,19 @@ public Task> ExecuteApproved( strategy, cancellationToken); + /// + /// Resolves the governed request and builds execution context for the core mutation engine. + /// + /// The state type handled by the governed mutation. + /// Stable identifier of the governed mutation request. + /// Mutation instance to wrap for governed execution. + /// Current state snapshot used for execution. + /// Current version or concurrency token of the state snapshot. + /// Delegate that resolves the resulting state version from the post-mutation state. + /// Context describing the governance actor or service performing execution. + /// Version-resolution strategy applied before execution. + /// Cancellation token. + /// Resolved execution context passed to the core mutation engine. private async Task> ResolveExecutionContext( string requestId, IMutation mutation, @@ -123,6 +169,13 @@ private async Task> ResolveExecutionContext + /// Executes the wrapped governed mutation through the core mutation engine. + /// + /// The state type handled by the governed mutation. + /// Resolved governed execution context. + /// Cancellation token. + /// The core mutation result. private Task> ExecuteMutation( GovernedExecutionContext execution, CancellationToken cancellationToken) @@ -130,4 +183,66 @@ private Task> ExecuteMutation( execution.Mutation, execution.CurrentState, cancellationToken); + + /// + /// Links successful compensating execution back to the original governed request. + /// + /// Persisted compensating request after successful execution. + /// Context describing the governance actor or service recording the compensation link. + /// Cancellation token. + private async Task LinkCompensationExecution( + MutationRequest executedRequest, + MutationContext governanceContext, + CancellationToken cancellationToken) + { + if (executedRequest.Execution.Kind != GovernedExecutionKind.Compensation || executedRequest.Execution.Compensation is null) + return; + + executedRequest.Execution.Compensation.EnsureValid(); + + var originalRequest = await _requestStore + .Get(executedRequest.Execution.Compensation.OriginalRequestId, cancellationToken) + .ConfigureAwait(false) + ?? throw new MutationRequestNotFoundException(executedRequest.Execution.Compensation.OriginalRequestId); + + if (originalRequest.Execution.RelatedExecutions.Any(link => link.RequestId == executedRequest.RequestId && + link.Type == GovernedExecutionLinkType.CompensatedBy)) + return; + + var linkedAt = executedRequest.Versioning.ExecutedAt ?? DateTimeOffset.UtcNow; + var decision = GovernedExecutionDecisionFactory.CreateCompensatedDecision( + governanceContext, + executedRequest.RequestId, + executedRequest.Execution.Compensation, + executedRequest.Versioning.ResultingStateVersion ?? string.Empty, + linkedAt); + + var updatedOriginalRequest = originalRequest with + { + UpdatedAt = linkedAt, + Execution = originalRequest.Execution with + { + RelatedExecutions = + [ + .. originalRequest.Execution.RelatedExecutions, + new GovernedExecutionLink + { + RequestId = executedRequest.RequestId, + Type = GovernedExecutionLinkType.CompensatedBy, + ExecutionKind = GovernedExecutionKind.Compensation, + CompensationKind = executedRequest.Execution.Compensation.Kind, + Trigger = executedRequest.Execution.Compensation.Trigger, + BatchId = executedRequest.Execution.Compensation.BatchId, + LinkedAt = linkedAt + } + ] + }, + Decisions = [.. originalRequest.Decisions, decision] + }; + + var persistedRequest = await _requestStore + .TryStore(updatedOriginalRequest, originalRequest.Revision, cancellationToken) + .ConfigureAwait(false) + ?? throw new MutationRequestConcurrencyException(originalRequest.RequestId, originalRequest.Revision); + } } diff --git a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs index e360e24..ba5d888 100644 --- a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs +++ b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs @@ -1,5 +1,8 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; namespace ModularityKit.Mutator.Governance.Runtime.Execution.Outcome; @@ -9,35 +12,110 @@ namespace ModularityKit.Mutator.Governance.Runtime.Execution.Outcome; /// internal static class GovernedExecutionDecisionFactory { + /// + /// Creates lifecycle decision describing rejected governed execution. + /// + /// Governed request whose execution was rejected. + /// Context describing the actor or service recording the rejection. + /// Human-readable rejection reason. + /// Additional metadata captured for the rejection decision. + /// A lifecycle decision representing rejected execution. public static MutationRequestDecision CreateRejectedDecision( + MutationRequest request, MutationContext governanceContext, string reason, IReadOnlyDictionary metadata) { + var mergedMetadata = new Dictionary(metadata) + { + ["ExecutionKind"] = request.Execution.Kind.ToString() + }; + + AppendCompensationMetadata(mergedMetadata, request.Execution.Compensation); + return MutationRequestDecision.Lifecycle( MutationRequestLifecycleDecisionType.Rejected, governanceContext, reason, - metadata); + mergedMetadata); } + /// + /// Creates lifecycle decision describing successful governed execution. + /// + /// The state type handled by the governed mutation. + /// Governed request whose execution completed successfully. + /// Context describing the actor or service recording the execution. + /// Resulting state version persisted after successful execution. + /// Core mutation result used to populate decision metadata. + /// A lifecycle decision representing successful execution. public static MutationRequestDecision CreateExecutedDecision( + MutationRequest request, MutationContext governanceContext, string resultingStateVersion, MutationResult mutationResult) { + var metadata = new Dictionary + { + ["ExecutionKind"] = request.Execution.Kind.ToString(), + ["ResultingStateVersion"] = resultingStateVersion, + ["ChangeCount"] = mutationResult.Changes.Count, + ["SideEffectCount"] = mutationResult.SideEffects.Count + }; + + AppendCompensationMetadata(metadata, request.Execution.Compensation); + return MutationRequestDecision.Lifecycle( MutationRequestLifecycleDecisionType.Executed, governanceContext, - "Governed request executed successfully.", - new Dictionary - { - ["ResultingStateVersion"] = resultingStateVersion, - ["ChangeCount"] = mutationResult.Changes.Count, - ["SideEffectCount"] = mutationResult.SideEffects.Count - }); + request.Execution.Kind == GovernedExecutionKind.Compensation + ? "Governed compensation executed successfully." + : "Governed request executed successfully.", + metadata); } + /// + /// Creates lifecycle decision linking original request to successful compensating execution. + /// + /// Context describing the actor or service recording the compensation link. + /// Identifier of the compensating request. + /// Compensation plan associated with the compensating request. + /// Resulting state version produced by the compensation. + /// Timestamp to record on the compensation decision. + /// A lifecycle decision representing compensation of the original request. + public static MutationRequestDecision CreateCompensatedDecision( + MutationContext governanceContext, + string compensationRequestId, + GovernedCompensationPlan compensation, + string resultingStateVersion, + DateTimeOffset timestamp) + { + var metadata = new Dictionary + { + ["CompensationRequestId"] = compensationRequestId, + ["ResultingStateVersion"] = resultingStateVersion, + ["CompensationKind"] = compensation.Kind.ToString(), + ["CompensationTrigger"] = compensation.Trigger.ToString() + }; + + AppendCompensationMetadata(metadata, compensation); + + return MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Compensated, + governanceContext, + $"Compensated by request '{compensationRequestId}'.", + metadata) with + { + Timestamp = timestamp + }; + } + + /// + /// Builds rejection reason from failed mutation result. + /// + /// The state type handled by the governed mutation. + /// Failed mutation result to inspect. + /// Human-readable rejection reason derived from policy, validation, or default fallback. public static string BuildRejectedExecutionReason(MutationResult mutationResult) { if (mutationResult.PolicyDecisions.Count > 0) @@ -48,4 +126,32 @@ public static string BuildRejectedExecutionReason(MutationResult return "Governed execution completed without a successful mutation result."; } + + /// + /// Appends compensation metadata to decision metadata map when compensation context exists. + /// + /// Decision metadata map to enrich. + /// Optional compensation plan providing additional metadata. + private static void AppendCompensationMetadata( + IDictionary metadata, + GovernedCompensationPlan? compensation) + { + ArgumentNullException.ThrowIfNull(metadata); + + if (compensation is null) + return; + + metadata["OriginalRequestId"] = compensation.OriginalRequestId; + metadata["CompensationKind"] = compensation.Kind.ToString(); + metadata["CompensationTrigger"] = compensation.Trigger.ToString(); + + if (!string.IsNullOrWhiteSpace(compensation.BatchId)) + metadata["BatchId"] = compensation.BatchId; + + if (compensation.RelatedRequestIds.Count > 0) + metadata["RelatedRequestIds"] = compensation.RelatedRequestIds; + + if (!string.IsNullOrWhiteSpace(compensation.Reason)) + metadata["CompensationReason"] = compensation.Reason; + } } diff --git a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs index d960f36..4ab1e51 100644 --- a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs +++ b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs @@ -17,9 +17,15 @@ internal sealed class GovernedExecutionOutcomeHandler(GovernedExecutionRequestPe { private readonly GovernedExecutionRequestPersistence _persistence = persistence ?? throw new ArgumentNullException(nameof(persistence)); + /// + /// Persists rejected execution outcome when governed execution throws exception. + /// + /// The state type handled by the governed mutation. + /// Resolved governed execution context. + /// Exception thrown during governed execution. + /// Cancellation token. public async Task PersistException( - GovernedExecutionContext execution, Exception exception, CancellationToken cancellationToken) - { + GovernedExecutionContext execution, Exception exception, CancellationToken cancellationToken) => await PersistRejectedExecution( execution.Resolution.Request, execution.GovernanceContext, @@ -27,8 +33,17 @@ await PersistRejectedExecution( GovernedExecutionFailureMetadataFactory.CreateExceptionMetadata(execution.CurrentStateVersion, exception), sideEffects: null, cancellationToken).ConfigureAwait(false); - } + /// + /// Persists rejected governed request state and appends rejection decision. + /// + /// Current persisted request snapshot. + /// Context describing the actor or service recording the rejection. + /// Human-readable rejection reason. + /// Additional metadata captured for the rejection decision. + /// Optional side effects to persist with the rejected request. + /// Cancellation token. + /// The persisted rejected request snapshot. public async Task PersistRejectedExecution( MutationRequest request, MutationContext governanceContext, @@ -38,6 +53,7 @@ public async Task PersistRejectedExecution( CancellationToken cancellationToken) { var decision = GovernedExecutionDecisionFactory.CreateRejectedDecision( + request, governanceContext, reason, metadata); @@ -54,6 +70,16 @@ public async Task PersistRejectedExecution( return await _persistence.Persist(request, rejectedRequest, cancellationToken).ConfigureAwait(false); } + /// + /// Persists successful governed execution state and appends executed decision. + /// + /// The state type handled by the governed mutation. + /// Current persisted request snapshot. + /// Resulting state version produced by successful execution. + /// Context describing the actor or service recording the execution. + /// Core mutation result to persist. + /// Cancellation token. + /// The persisted executed request snapshot. public async Task PersistExecutedRequest( MutationRequest request, string resultingStateVersion, @@ -62,6 +88,7 @@ public async Task PersistExecutedRequest( CancellationToken cancellationToken) { var decision = GovernedExecutionDecisionFactory.CreateExecutedDecision( + request, governanceContext, resultingStateVersion, mutationResult); @@ -70,9 +97,12 @@ public async Task PersistExecutedRequest( { Status = MutationRequestStatus.Executed, PendingReason = null, - ExpectedStateVersion = resultingStateVersion, - ResultingStateVersion = resultingStateVersion, - ExecutedAt = decision.Timestamp, + Versioning = request.Versioning with + { + ExpectedStateVersion = resultingStateVersion, + ResultingStateVersion = resultingStateVersion, + ExecutedAt = decision.Timestamp + }, UpdatedAt = decision.Timestamp, Decisions = [.. request.Decisions, decision], SideEffects = mutationResult.SideEffects.ToList() @@ -81,6 +111,13 @@ public async Task PersistExecutedRequest( return await _persistence.Persist(request, executedRequest, cancellationToken).ConfigureAwait(false); } + /// + /// Builds result object for request that did not execute through the core mutation engine. + /// + /// The state type handled by the governed mutation. + /// Version-resolution outcome and latest persisted request snapshot. + /// Optional mutation result when execution reached the engine but did not complete successfully. + /// Governed execution result describing a non-executed request. public GovernedExecutionResult BuildNonExecutedResult( MutationRequestVersionResolution resolution, MutationResult? mutationResult = null) => new() @@ -88,9 +125,20 @@ public GovernedExecutionResult BuildNonExecutedResult( Request = resolution.Request, Resolution = resolution, MutationResult = mutationResult, - WasExecuted = false + WasExecuted = false, + ExecutionKind = resolution.Request.Execution.Kind, + Compensation = resolution.Request.Execution.Compensation }; + /// + /// Builds result object for request that executed successfully. + /// + /// The state type handled by the governed mutation. + /// Version-resolution outcome that gated execution. + /// Core mutation result produced by successful execution. + /// Persisted executed request snapshot. + /// Resulting state version produced by successful execution. + /// Governed execution result describing a successful execution. public GovernedExecutionResult BuildExecutedResult(MutationRequestVersionResolution resolution, MutationResult mutationResult, MutationRequest executedRequest, string resultingStateVersion) => new() @@ -99,10 +147,20 @@ public GovernedExecutionResult BuildExecutedResult(MutationReque Resolution = resolution with { Request = executedRequest }, MutationResult = mutationResult, WasExecuted = true, + ExecutionKind = executedRequest.Execution.Kind, + Compensation = executedRequest.Execution.Compensation, ResultingStateVersion = resultingStateVersion }; + /// + /// Maps core mutation result into rejected or executed governed request outcome. + /// + /// The state type handled by the governed mutation. + /// Resolved governed execution context. + /// Core mutation result to interpret. + /// Cancellation token. + /// Governed execution result after persisting the terminal request state. public async Task> HandleMutationResult( GovernedExecutionContext execution, MutationResult mutationResult, CancellationToken cancellationToken) { diff --git a/src/Governance/Runtime/Execution/Persistence/GovernedExecutionRequestPersistence.cs b/src/Governance/Runtime/Execution/Persistence/GovernedExecutionRequestPersistence.cs index 8132def..7769bcb 100644 --- a/src/Governance/Runtime/Execution/Persistence/GovernedExecutionRequestPersistence.cs +++ b/src/Governance/Runtime/Execution/Persistence/GovernedExecutionRequestPersistence.cs @@ -11,6 +11,16 @@ internal sealed class GovernedExecutionRequestPersistence(IMutationRequestStore { private readonly IMutationRequestStore _requestStore = requestStore ?? throw new ArgumentNullException(nameof(requestStore)); + /// + /// Persists next governed request snapshot when the previous revision still matches storage. + /// + /// Previously persisted request snapshot that provides the expected revision. + /// Next request snapshot to persist. + /// Cancellation token. + /// The persisted request snapshot with updated revision. + /// + /// Thrown when the persisted request revision no longer matches the expected revision. + /// public async Task Persist( MutationRequest previousRequest, MutationRequest nextRequest, @@ -20,9 +30,8 @@ public async Task Persist( .TryStore(nextRequest, previousRequest.Revision, cancellationToken) .ConfigureAwait(false); - if (persistedRequest is null) - throw new MutationRequestConcurrencyException(previousRequest.RequestId, previousRequest.Revision); - - return persistedRequest; + return persistedRequest is null + ? throw new MutationRequestConcurrencyException(previousRequest.RequestId, previousRequest.Revision) + : persistedRequest; } } From 819593e871e4cfedab6b49232efd48ea735e4641 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 18:44:58 +0200 Subject: [PATCH 04/17] Document governance runtime resolution helpers --- src/Governance/README.md | 6 +- .../MutationRequestVersionEvaluator.cs | 9 +- .../MutationRequestVersionResolutionState.cs | 105 +++++++++--------- 3 files changed, 64 insertions(+), 56 deletions(-) diff --git a/src/Governance/README.md b/src/Governance/README.md index c497cda..23dbc34 100644 --- a/src/Governance/README.md +++ b/src/Governance/README.md @@ -90,6 +90,8 @@ decision wrapper when the category is already known. - `IGovernanceExecutionManager` - `GovernanceExecutionManager` - `GovernedExecutionResult` +- `GovernedCompensationPlan` +- `GovernedExecutionKind` `IGovernanceExecutionManager.ExecuteApproved(...)` also has an overload for `IVersionedState` implementations, which removes the need to pass the current and resulting version selectors when @@ -137,11 +139,13 @@ Included today: - optimistic concurrency in request storage - version-aware resolution before execution - governed execution orchestration +- compensation-aware governed execution links and history - in-memory support for local runtime scenarios Not included yet: - production persistence providers such as EF Core or PostgreSQL - reporting/query stores for operational governance views -- compensation or retry orchestration +- distributed saga-style compensation orchestration +- retry orchestration - external approval system integrations diff --git a/src/Governance/Runtime/Resolution/Evaluation/MutationRequestVersionEvaluator.cs b/src/Governance/Runtime/Resolution/Evaluation/MutationRequestVersionEvaluator.cs index da7c6ca..17caeea 100644 --- a/src/Governance/Runtime/Resolution/Evaluation/MutationRequestVersionEvaluator.cs +++ b/src/Governance/Runtime/Resolution/Evaluation/MutationRequestVersionEvaluator.cs @@ -3,13 +3,16 @@ namespace ModularityKit.Mutator.Governance.Runtime.Resolution.Evaluation; /// -/// Evaluates whether a governed request still matches the currently observed state version. +/// Evaluates whether governed request still matches the currently observed state version. /// internal static class MutationRequestVersionEvaluator { /// - /// Compares the request expected version with the current state version and returns a normalized evaluation model. + /// Compares the request expected version with the current state version and returns normalized evaluation model. /// + /// Governed request whose expected version should be evaluated. + /// Currently observed state version. + /// Normalized version evaluation used by governance resolution. public static MutationRequestVersionEvaluation Evaluate( MutationRequest request, string currentStateVersion) @@ -19,7 +22,7 @@ public static MutationRequestVersionEvaluation Evaluate( if (string.IsNullOrWhiteSpace(currentStateVersion)) throw new ArgumentException("Current state version is required.", nameof(currentStateVersion)); - var expectedStateVersion = request.ExpectedStateVersion; + var expectedStateVersion = request.Versioning.ExpectedStateVersion; var isStale = !string.IsNullOrWhiteSpace(expectedStateVersion) && !string.Equals(expectedStateVersion, currentStateVersion, StringComparison.Ordinal); diff --git a/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs index 95130f3..e1fa044 100644 --- a/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs +++ b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs @@ -11,107 +11,108 @@ namespace ModularityKit.Mutator.Governance.Runtime.Resolution.Execution; internal static class MutationRequestVersionResolutionState { /// - /// Appends a decision to the request decision history. + /// Appends decision to the request decision history. /// - public static MutationRequest AppendDecision( - MutationRequest request, - MutationRequestDecision decision) - { - return request with + /// Request snapshot to update. + /// Decision to append to the request history. + /// Updated request snapshot with appended decision. + public static MutationRequest AppendDecision(MutationRequest request, MutationRequestDecision decision) => + request with { Decisions = [.. request.Decisions, decision] }; - } /// - /// Applies the rejected-as-stale state transition. + /// Applies the rejected as stale state transition. /// - public static MutationRequest ApplyRejectedAsStale( - MutationRequest request, - string currentStateVersion, - MutationRequestDecision decision) - { - return AppendDecision( - request with - { - Status = MutationRequestStatus.Rejected, - PendingReason = null, - UpdatedAt = decision.Timestamp - }, - decision); - } + /// Request snapshot to update. + /// Currently observed state version. + /// Version-resolution decision to append. + /// Updated request snapshot marked as rejected. + public static MutationRequest ApplyRejectedAsStale(MutationRequest request, string currentStateVersion, MutationRequestDecision decision) => AppendDecision( + request with + { + Status = MutationRequestStatus.Rejected, + PendingReason = null, + UpdatedAt = decision.Timestamp + }, decision); /// - /// Applies the renewed-approval-required state transition. + /// Applies the renewed approval required state transition. /// + /// Request snapshot to update. + /// Currently observed state version. + /// Version-resolution decision to append. + /// Updated request snapshot moved back to pending approval. public static MutationRequest ApplyRenewedApprovalRequired( MutationRequest request, string currentStateVersion, - MutationRequestDecision decision) - { - return AppendDecision( + MutationRequestDecision decision) => AppendDecision( request with { Status = MutationRequestStatus.Pending, PendingReason = PendingMutationReason.Approval, - ExpectedStateVersion = currentStateVersion, + Versioning = request.Versioning with + { + ExpectedStateVersion = currentStateVersion + }, UpdatedAt = decision.Timestamp }, decision); - } /// - /// Applies the revalidation-required state transition. + /// Applies the revalidation required state transition. /// + /// Request snapshot to update. + /// Currently observed state version. + /// Version-resolution decision to append. + /// Updated request snapshot moved to pending revalidation. public static MutationRequest ApplyRevalidationRequired( MutationRequest request, string currentStateVersion, - MutationRequestDecision decision) - { - return AppendDecision( + MutationRequestDecision decision) => AppendDecision( request with { Status = MutationRequestStatus.Pending, PendingReason = PendingMutationReason.Revalidation, - ExpectedStateVersion = currentStateVersion, + Versioning = request.Versioning with + { + ExpectedStateVersion = currentStateVersion + }, UpdatedAt = decision.Timestamp }, decision); - } + /// /// Builds metadata describing the expected and current state versions used during resolution. /// - public static IReadOnlyDictionary CreateVersionMetadata( - string? expectedStateVersion, - string currentStateVersion) - { - return new Dictionary + /// Expected request state version captured before resolution. + /// Currently observed state version. + /// Metadata map describing the compared versions. + public static IReadOnlyDictionary CreateVersionMetadata(string? expectedStateVersion, string currentStateVersion) => + new Dictionary { ["ExpectedStateVersion"] = expectedStateVersion ?? string.Empty, ["CurrentStateVersion"] = currentStateVersion }; - } /// - /// Builds the success reason for a request whose expected and current versions match. + /// Builds the success reason for request whose expected and current versions match. /// + /// Expected request state version captured before resolution. + /// Currently observed state version. public static string BuildValidatedReason( string? expectedStateVersion, - string currentStateVersion) - { - return string.IsNullOrWhiteSpace(expectedStateVersion) + string currentStateVersion) => string.IsNullOrWhiteSpace(expectedStateVersion) ? "No expected state version was provided. Request can proceed." : $"State version '{currentStateVersion}' matches the expected version."; - } /// - /// Builds the stale-version explanation used by stale resolution decisions. + /// Builds the stale version explanation used by stale resolution decisions. /// - public static string BuildStaleReason( - string expectedStateVersion, - string currentStateVersion) - { - return $"Request expected state version '{expectedStateVersion}' but current version is '{currentStateVersion}'."; - } + /// Expected request state version captured before resolution. + /// Currently observed state version. + public static string BuildStaleReason(string expectedStateVersion, string currentStateVersion) => + $"Request expected state version '{expectedStateVersion}' but current version is '{currentStateVersion}'."; } From 60ca234ee62915f181cd9546e7016acc8c08f7ed Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 19:32:28 +0200 Subject: [PATCH 05/17] Reorganize core runtime test fixtures --- .../MutationEngineBatchExecutionTests.cs | 41 ++++ .../MutationEngineConcurrencyGateTests.cs | 84 +++++++ .../{ => Engine}/MutationEnginePolicyTests.cs | 0 ...utatorsServiceCollectionExtensionsTests.cs | 19 ++ .../{ => Mutation}/MutationBaseTests.cs | 2 +- .../Runtime/MutationEngineConcurrencyTests.cs | 221 ------------------ .../Engine/BlockingMutationGate.cs | 55 +++++ .../TestSupport/Engine/OrderedState.cs | 6 + .../TestSupport/Mutations/BlockingMutation.cs | 37 +++ .../TestSupport/Mutations/OrderedMutation.cs | 37 +++ 10 files changed, 280 insertions(+), 222 deletions(-) create mode 100644 Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEngineBatchExecutionTests.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEngineConcurrencyGateTests.cs rename Tests/ModularityKit.Mutator.Tests/Runtime/{ => Engine}/MutationEnginePolicyTests.cs (100%) create mode 100644 Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutatorsServiceCollectionExtensionsTests.cs rename Tests/ModularityKit.Mutator.Tests/Runtime/{ => Mutation}/MutationBaseTests.cs (98%) delete mode 100644 Tests/ModularityKit.Mutator.Tests/Runtime/MutationEngineConcurrencyTests.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/BlockingMutationGate.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/OrderedState.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Mutations/BlockingMutation.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Mutations/OrderedMutation.cs diff --git a/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEngineBatchExecutionTests.cs b/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEngineBatchExecutionTests.cs new file mode 100644 index 0000000..35b232c --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEngineBatchExecutionTests.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Concurrent; +using ModularityKit.Mutator.Abstractions; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Runtime; +using ModularityKit.Mutator.Tests.TestSupport.Engine; +using ModularityKit.Mutator.Tests.TestSupport.Mutations; +using Xunit; + +namespace ModularityKit.Mutator.Tests.Runtime.Engine; + +public sealed class MutationEngineBatchExecutionTests +{ + [Fact] + public async Task ExecuteBatchAsync_remains_ordered_while_respecting_runtime_concurrency_gates() + { + var services = new ServiceCollection(); + services.AddMutators(configure: options => + { + options.MaxConcurrentMutations = 2; + options.EnableDetailedMetrics = false; + }); + + await using var provider = services.BuildServiceProvider(); + var engine = provider.GetRequiredService(); + var observed = new ConcurrentQueue(); + + var batch = new[] + { + new OrderedMutation("state-1", "first", observed), + new OrderedMutation("state-2", "second", observed), + new OrderedMutation("state-1", "third", observed) + }; + + var result = await engine.ExecuteBatchAsync(batch, new OrderedState("initial")); + + Assert.True(result.IsSuccess); + Assert.Equal(3, result.Results.Count); + Assert.Equal(new[] { "first", "second", "third" }, observed); + } +} diff --git a/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEngineConcurrencyGateTests.cs b/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEngineConcurrencyGateTests.cs new file mode 100644 index 0000000..07839d1 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEngineConcurrencyGateTests.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.DependencyInjection; +using ModularityKit.Mutator.Abstractions; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Runtime; +using ModularityKit.Mutator.Tests.TestSupport.Engine; +using ModularityKit.Mutator.Tests.TestSupport.Mutations; +using Xunit; + +namespace ModularityKit.Mutator.Tests.Runtime.Engine; + +public sealed class MutationEngineConcurrencyGateTests +{ + [Fact] + public async Task ExecuteAsync_serializes_mutations_that_target_the_same_state_id() + { + var services = new ServiceCollection(); + services.AddMutators(configure: options => + { + options.MaxConcurrentMutations = 4; + options.EnableDetailedMetrics = false; + }); + + await using var provider = services.BuildServiceProvider(); + var engine = provider.GetRequiredService(); + using var gate = new BlockingMutationGate(); + var state = new OrderedState("initial"); + + var first = new BlockingMutation(gate, "shared-state", "first"); + var second = new BlockingMutation(gate, "shared-state", "second"); + + var firstTask = Task.Run(() => engine.ExecuteAsync(first, state)); + var secondTask = Task.Run(() => engine.ExecuteAsync(second, state)); + + Assert.True(await gate.WaitForEntriesAsync(1, TimeSpan.FromSeconds(5))); + Assert.Equal(1, gate.PeakConcurrency); + + gate.Release(); + + var results = await Task.WhenAll(firstTask, secondTask); + + Assert.All(results, result => Assert.True(result.IsSuccess)); + Assert.Equal(1, gate.PeakConcurrency); + } + + [Fact] + public async Task ExecuteAsync_honors_max_concurrent_mutations_for_different_states() + { + var services = new ServiceCollection(); + services.AddMutators(configure: options => + { + options.MaxConcurrentMutations = 2; + options.EnableDetailedMetrics = false; + }); + + await using var provider = services.BuildServiceProvider(); + var engine = provider.GetRequiredService(); + using var gate = new BlockingMutationGate(); + var states = new[] + { + new OrderedState("one"), + new OrderedState("two"), + new OrderedState("three"), + new OrderedState("four") + }; + + var tasks = new[] + { + Task.Run(() => engine.ExecuteAsync(new BlockingMutation(gate, "state-1", "one"), states[0])), + Task.Run(() => engine.ExecuteAsync(new BlockingMutation(gate, "state-2", "two"), states[1])), + Task.Run(() => engine.ExecuteAsync(new BlockingMutation(gate, "state-3", "three"), states[2])), + Task.Run(() => engine.ExecuteAsync(new BlockingMutation(gate, "state-4", "four"), states[3])) + }; + + Assert.True(await gate.WaitForEntriesAsync(2, TimeSpan.FromSeconds(5))); + Assert.Equal(2, gate.PeakConcurrency); + + gate.Release(); + + var results = await Task.WhenAll(tasks); + + Assert.All(results, result => Assert.True(result.IsSuccess)); + Assert.Equal(2, gate.PeakConcurrency); + } +} diff --git a/Tests/ModularityKit.Mutator.Tests/Runtime/MutationEnginePolicyTests.cs b/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEnginePolicyTests.cs similarity index 100% rename from Tests/ModularityKit.Mutator.Tests/Runtime/MutationEnginePolicyTests.cs rename to Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEnginePolicyTests.cs diff --git a/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutatorsServiceCollectionExtensionsTests.cs b/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutatorsServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..7dedd7e --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutatorsServiceCollectionExtensionsTests.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; +using ModularityKit.Mutator.Abstractions; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Runtime; +using Xunit; + +namespace ModularityKit.Mutator.Tests.Runtime.Engine; + +public sealed class MutatorsServiceCollectionExtensionsTests +{ + [Fact] + public void AddMutators_rejects_non_positive_max_concurrent_mutations() + { + var services = new ServiceCollection(); + services.AddMutators(configure: options => options.MaxConcurrentMutations = 0); + + Assert.Throws(() => services.BuildServiceProvider().GetRequiredService()); + } +} diff --git a/Tests/ModularityKit.Mutator.Tests/Runtime/MutationBaseTests.cs b/Tests/ModularityKit.Mutator.Tests/Runtime/Mutation/MutationBaseTests.cs similarity index 98% rename from Tests/ModularityKit.Mutator.Tests/Runtime/MutationBaseTests.cs rename to Tests/ModularityKit.Mutator.Tests/Runtime/Mutation/MutationBaseTests.cs index 79fe974..568d8d6 100644 --- a/Tests/ModularityKit.Mutator.Tests/Runtime/MutationBaseTests.cs +++ b/Tests/ModularityKit.Mutator.Tests/Runtime/Mutation/MutationBaseTests.cs @@ -7,7 +7,7 @@ using ModularityKit.Mutator.Runtime; using Xunit; -namespace ModularityKit.Mutator.Tests.Runtime; +namespace ModularityKit.Mutator.Tests.Runtime.Mutation; public sealed class MutationBaseTests { diff --git a/Tests/ModularityKit.Mutator.Tests/Runtime/MutationEngineConcurrencyTests.cs b/Tests/ModularityKit.Mutator.Tests/Runtime/MutationEngineConcurrencyTests.cs deleted file mode 100644 index b7705da..0000000 --- a/Tests/ModularityKit.Mutator.Tests/Runtime/MutationEngineConcurrencyTests.cs +++ /dev/null @@ -1,221 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using System.Collections.Concurrent; -using ModularityKit.Mutator.Abstractions; -using ModularityKit.Mutator.Abstractions.Changes; -using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Abstractions.Engine; -using ModularityKit.Mutator.Abstractions.Intent; -using ModularityKit.Mutator.Abstractions.Results; -using ModularityKit.Mutator.Runtime; -using Xunit; - -namespace ModularityKit.Mutator.Tests.Runtime; - -public sealed class MutationEngineConcurrencyTests -{ - [Fact] - public async Task ExecuteAsync_serializes_mutations_that_target_the_same_state_id() - { - var services = new ServiceCollection(); - services.AddMutators(configure: options => - { - options.MaxConcurrentMutations = 4; - options.EnableDetailedMetrics = false; - }); - - await using var provider = services.BuildServiceProvider(); - var engine = provider.GetRequiredService(); - using var gate = new BlockingMutationGate(); - var state = new OrderedState("initial"); - - var first = new BlockingMutation(gate, "shared-state", "first"); - var second = new BlockingMutation(gate, "shared-state", "second"); - - var firstTask = Task.Run(() => engine.ExecuteAsync(first, state)); - var secondTask = Task.Run(() => engine.ExecuteAsync(second, state)); - - Assert.True(await gate.WaitForEntriesAsync(1, TimeSpan.FromSeconds(5))); - Assert.Equal(1, gate.PeakConcurrency); - - gate.Release(); - - var results = await Task.WhenAll(firstTask, secondTask); - - Assert.All(results, result => Assert.True(result.IsSuccess)); - Assert.Equal(1, gate.PeakConcurrency); - } - - [Fact] - public async Task ExecuteAsync_honors_max_concurrent_mutations_for_different_states() - { - var services = new ServiceCollection(); - services.AddMutators(configure: options => - { - options.MaxConcurrentMutations = 2; - options.EnableDetailedMetrics = false; - }); - - await using var provider = services.BuildServiceProvider(); - var engine = provider.GetRequiredService(); - using var gate = new BlockingMutationGate(); - var states = new[] - { - new OrderedState("one"), - new OrderedState("two"), - new OrderedState("three"), - new OrderedState("four") - }; - - var tasks = new[] - { - Task.Run(() => engine.ExecuteAsync(new BlockingMutation(gate, "state-1", "one"), states[0])), - Task.Run(() => engine.ExecuteAsync(new BlockingMutation(gate, "state-2", "two"), states[1])), - Task.Run(() => engine.ExecuteAsync(new BlockingMutation(gate, "state-3", "three"), states[2])), - Task.Run(() => engine.ExecuteAsync(new BlockingMutation(gate, "state-4", "four"), states[3])) - }; - - Assert.True(await gate.WaitForEntriesAsync(2, TimeSpan.FromSeconds(5))); - Assert.Equal(2, gate.PeakConcurrency); - - gate.Release(); - - var results = await Task.WhenAll(tasks); - - Assert.All(results, result => Assert.True(result.IsSuccess)); - Assert.Equal(2, gate.PeakConcurrency); - } - - [Fact] - public async Task ExecuteBatchAsync_remains_ordered_while_respecting_runtime_concurrency_gates() - { - var services = new ServiceCollection(); - services.AddMutators(configure: options => - { - options.MaxConcurrentMutations = 2; - options.EnableDetailedMetrics = false; - }); - - await using var provider = services.BuildServiceProvider(); - var engine = provider.GetRequiredService(); - var observed = new ConcurrentQueue(); - - var batch = new[] - { - new OrderedMutation("state-1", "first", observed), - new OrderedMutation("state-2", "second", observed), - new OrderedMutation("state-1", "third", observed) - }; - - var result = await engine.ExecuteBatchAsync(batch, new OrderedState("initial")); - - Assert.True(result.IsSuccess); - Assert.Equal(3, result.Results.Count); - Assert.Equal(new[] { "first", "second", "third" }, observed); - } - - [Fact] - public void AddMutators_rejects_non_positive_max_concurrent_mutations() - { - var services = new ServiceCollection(); - services.AddMutators(configure: options => options.MaxConcurrentMutations = 0); - - Assert.Throws(() => services.BuildServiceProvider().GetRequiredService()); - } - - private sealed record OrderedState(string Value); - - private sealed class OrderedMutation(string stateId, string value, ConcurrentQueue observed) - : IMutation - { - public MutationIntent Intent { get; } = new() - { - OperationName = "Order", - Category = "Test", - Description = "Observe execution order" - }; - - public MutationContext Context { get; } = MutationContext.User("tester", "Tester", "Order test") - with { StateId = stateId }; - - public MutationResult Apply(OrderedState state) - { - observed.Enqueue(value); - return MutationResult.Success(state with { Value = value }, ChangeSet.Empty); - } - - public ValidationResult Validate(OrderedState state) => ValidationResult.Success(); - - public MutationResult Simulate(OrderedState state) => Apply(state); - } - - private sealed class BlockingMutationGate : IDisposable - { - private readonly ManualResetEventSlim _release = new(false); - private int _entered; - private int _active; - private int _peak; - - public int PeakConcurrency => Volatile.Read(ref _peak); - - public async Task WaitForEntriesAsync(int expectedEntries, TimeSpan timeout) - { - var started = DateTimeOffset.UtcNow; - - while (Volatile.Read(ref _entered) < expectedEntries) - { - if (DateTimeOffset.UtcNow - started > timeout) - return false; - - await Task.Delay(10); - } - - return true; - } - - public void Enter() - { - Interlocked.Increment(ref _entered); - var active = Interlocked.Increment(ref _active); - - while (true) - { - var peak = Volatile.Read(ref _peak); - if (active <= peak || Interlocked.CompareExchange(ref _peak, active, peak) == peak) - break; - } - - _release.Wait(); - Interlocked.Decrement(ref _active); - } - - public void Release() => _release.Set(); - - public void Dispose() => _release.Dispose(); - } - - private sealed class BlockingMutation( - BlockingMutationGate gate, - string stateId, - string value) : IMutation - { - public MutationIntent Intent { get; } = new() - { - OperationName = "Block", - Category = "Test", - Description = "Block until released" - }; - - public MutationContext Context { get; } = MutationContext.User($"{stateId}-actor", $"{stateId}-actor", "Concurrency test") - with { StateId = stateId }; - - public MutationResult Apply(OrderedState state) - { - gate.Enter(); - return MutationResult.Success(state with { Value = value }, ChangeSet.Empty); - } - - public ValidationResult Validate(OrderedState state) => ValidationResult.Success(); - - public MutationResult Simulate(OrderedState state) => Apply(state); - } -} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/BlockingMutationGate.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/BlockingMutationGate.cs new file mode 100644 index 0000000..6e1229f --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/BlockingMutationGate.cs @@ -0,0 +1,55 @@ +namespace ModularityKit.Mutator.Tests.TestSupport.Engine; + +/// +/// Coordinates blocking test mutations and tracks observed concurrency. +/// +internal sealed class BlockingMutationGate : IDisposable +{ + private readonly ManualResetEventSlim _release = new(false); + private int _entered; + private int _active; + private int _peak; + + public int PeakConcurrency => Volatile.Read(ref _peak); + + /// + /// Waits until the expected number of mutations have entered the gate. + /// + /// Number of entries required before returning success. + /// Maximum time to wait. + /// when the expected number of entries arrived before timeout; otherwise . + public async Task WaitForEntriesAsync(int expectedEntries, TimeSpan timeout) + { + var started = DateTimeOffset.UtcNow; + + while (Volatile.Read(ref _entered) < expectedEntries) + { + if (DateTimeOffset.UtcNow - started > timeout) + return false; + + await Task.Delay(10); + } + + return true; + } + + public void Enter() + { + Interlocked.Increment(ref _entered); + var active = Interlocked.Increment(ref _active); + + while (true) + { + var peak = Volatile.Read(ref _peak); + if (active <= peak || Interlocked.CompareExchange(ref _peak, active, peak) == peak) + break; + } + + _release.Wait(); + Interlocked.Decrement(ref _active); + } + + public void Release() => _release.Set(); + + public void Dispose() => _release.Dispose(); +} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/OrderedState.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/OrderedState.cs new file mode 100644 index 0000000..e37fafe --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/OrderedState.cs @@ -0,0 +1,6 @@ +namespace ModularityKit.Mutator.Tests.TestSupport.Engine; + +/// +/// Sample state used by mutation engine concurrency and batch execution tests. +/// +internal sealed record OrderedState(string Value); diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Mutations/BlockingMutation.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Mutations/BlockingMutation.cs new file mode 100644 index 0000000..5e149a2 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Mutations/BlockingMutation.cs @@ -0,0 +1,37 @@ +using ModularityKit.Mutator.Abstractions.Changes; +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Tests.TestSupport.Engine; + +namespace ModularityKit.Mutator.Tests.TestSupport.Mutations; + +/// +/// Blocks mutation execution until the shared test gate is released. +/// +internal sealed class BlockingMutation( + BlockingMutationGate gate, + string stateId, + string value) : IMutation +{ + public MutationIntent Intent { get; } = new() + { + OperationName = "Block", + Category = "Test", + Description = "Block until released" + }; + + public MutationContext Context { get; } = MutationContext.User($"{stateId}-actor", $"{stateId}-actor", "Concurrency test") + with { StateId = stateId }; + + public MutationResult Apply(OrderedState state) + { + gate.Enter(); + return MutationResult.Success(state with { Value = value }, ChangeSet.Empty); + } + + public ValidationResult Validate(OrderedState state) => ValidationResult.Success(); + + public MutationResult Simulate(OrderedState state) => Apply(state); +} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Mutations/OrderedMutation.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Mutations/OrderedMutation.cs new file mode 100644 index 0000000..ebdd3a5 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Mutations/OrderedMutation.cs @@ -0,0 +1,37 @@ +using System.Collections.Concurrent; +using ModularityKit.Mutator.Abstractions.Changes; +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Tests.TestSupport.Engine; + +namespace ModularityKit.Mutator.Tests.TestSupport.Mutations; + +/// +/// Records execution order for batch execution tests. +/// +internal sealed class OrderedMutation(string stateId, string value, ConcurrentQueue observed) + : IMutation +{ + public MutationIntent Intent { get; } = new() + { + OperationName = "Order", + Category = "Test", + Description = "Observe execution order" + }; + + public MutationContext Context { get; } = MutationContext.User("tester", "Tester", "Order test") + with + { StateId = stateId }; + + public MutationResult Apply(OrderedState state) + { + observed.Enqueue(value); + return MutationResult.Success(state with { Value = value }, ChangeSet.Empty); + } + + public ValidationResult Validate(OrderedState state) => ValidationResult.Success(); + + public MutationResult Simulate(OrderedState state) => Apply(state); +} From 406543a8bc7dce2663ffcd335bbaed0d08ee0880 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 19:32:51 +0200 Subject: [PATCH 06/17] Extract runtime policy test support --- .../Engine/MutationEnginePolicyTests.cs | 166 ++---------------- .../Engine/Host/MutationEngineTestHost.cs | 26 +++ .../Engine/Policy/AsyncBlockingPolicy.cs | 24 +++ .../Engine/Policy/CancelAwareAsyncPolicy.cs | 24 +++ .../Engine/Policy/FailingAsyncPolicy.cs | 21 +++ .../Engine/Policy/ObservedAsyncAllowPolicy.cs | 25 +++ .../Engine/Policy/ObservedSyncAllowPolicy.cs | 21 +++ .../Engine/Policy/SlowAsyncPolicy.cs | 24 +++ .../Engine/Policy/SyncAllowPolicy.cs | 18 ++ .../Engine/Samples/PolicySampleState.cs | 6 + .../Mutations/PolicySampleMutation.cs | 27 +++ 11 files changed, 233 insertions(+), 149 deletions(-) create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Host/MutationEngineTestHost.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/AsyncBlockingPolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/CancelAwareAsyncPolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/FailingAsyncPolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/ObservedAsyncAllowPolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/ObservedSyncAllowPolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/SlowAsyncPolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/SyncAllowPolicy.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Samples/PolicySampleState.cs create mode 100644 Tests/ModularityKit.Mutator.Tests/TestSupport/Mutations/PolicySampleMutation.cs diff --git a/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEnginePolicyTests.cs b/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEnginePolicyTests.cs index 73563bc..59cfac2 100644 --- a/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEnginePolicyTests.cs +++ b/Tests/ModularityKit.Mutator.Tests/Runtime/Engine/MutationEnginePolicyTests.cs @@ -1,26 +1,22 @@ -using Microsoft.Extensions.DependencyInjection; -using ModularityKit.Mutator.Abstractions; -using ModularityKit.Mutator.Abstractions.Changes; -using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Engine; using ModularityKit.Mutator.Abstractions.Exceptions; -using ModularityKit.Mutator.Abstractions.Intent; -using ModularityKit.Mutator.Abstractions.Policies; -using ModularityKit.Mutator.Abstractions.Results; -using ModularityKit.Mutator.Runtime; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Host; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Policy; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; +using ModularityKit.Mutator.Tests.TestSupport.Mutations; using Xunit; -namespace ModularityKit.Mutator.Tests.Runtime; +namespace ModularityKit.Mutator.Tests.Runtime.Engine; public sealed class MutationEnginePolicyTests { [Fact] public async Task ExecuteAsync_supports_async_policy_evaluation() { - var engine = CreateEngine(); + var engine = MutationEngineTestHost.CreateEngine(); engine.RegisterPolicy(new AsyncBlockingPolicy()); - var result = await engine.ExecuteAsync(new SampleMutation(), new SampleState("initial")); + var result = await engine.ExecuteAsync(new PolicySampleMutation(), new PolicySampleState("initial")); Assert.False(result.IsSuccess); Assert.Single(result.PolicyDecisions); @@ -31,11 +27,11 @@ public async Task ExecuteAsync_supports_async_policy_evaluation() [Fact] public async Task ExecuteAsync_throws_policy_evaluation_timeout_exception_for_slow_policy() { - var engine = CreateEngine(options => options.PolicyEvaluationTimeout = TimeSpan.FromMilliseconds(50)); + var engine = MutationEngineTestHost.CreateEngine(options => options.PolicyEvaluationTimeout = TimeSpan.FromMilliseconds(50)); engine.RegisterPolicy(new SlowAsyncPolicy()); var exception = await Assert.ThrowsAsync(() => - engine.ExecuteAsync(new SampleMutation(), new SampleState("initial"))); + engine.ExecuteAsync(new PolicySampleMutation(), new PolicySampleState("initial"))); Assert.Equal("SlowExternalCheck", exception.PolicyName); } @@ -43,11 +39,11 @@ public async Task ExecuteAsync_throws_policy_evaluation_timeout_exception_for_sl [Fact] public async Task ExecuteAsync_wraps_policy_evaluation_failures() { - var engine = CreateEngine(); + var engine = MutationEngineTestHost.CreateEngine(); engine.RegisterPolicy(new FailingAsyncPolicy()); var exception = await Assert.ThrowsAsync(() => - engine.ExecuteAsync(new SampleMutation(), new SampleState("initial"))); + engine.ExecuteAsync(new PolicySampleMutation(), new PolicySampleState("initial"))); Assert.Equal("FailingExternalCheck", exception.PolicyName); Assert.IsType(exception.InnerException); @@ -56,21 +52,21 @@ public async Task ExecuteAsync_wraps_policy_evaluation_failures() [Fact] public async Task ExecuteAsync_preserves_caller_cancellation_during_policy_evaluation() { - var engine = CreateEngine(); + var engine = MutationEngineTestHost.CreateEngine(); engine.RegisterPolicy(new CancelAwareAsyncPolicy()); using var cancellationSource = new CancellationTokenSource(millisecondsDelay: 50); await Assert.ThrowsAnyAsync(() => - engine.ExecuteAsync(new SampleMutation(), new SampleState("initial"), cancellationSource.Token)); + engine.ExecuteAsync(new PolicySampleMutation(), new PolicySampleState("initial"), cancellationSource.Token)); } [Fact] public async Task ExecuteAsync_uses_sync_policy_path_without_async_override() { - var engine = CreateEngine(); + var engine = MutationEngineTestHost.CreateEngine(); engine.RegisterPolicy(new SyncAllowPolicy()); - var result = await engine.ExecuteAsync(new SampleMutation(), new SampleState("initial")); + var result = await engine.ExecuteAsync(new PolicySampleMutation(), new PolicySampleState("initial")); Assert.True(result.IsSuccess); Assert.Equal("updated", result.NewState!.Value); @@ -79,143 +75,15 @@ public async Task ExecuteAsync_uses_sync_policy_path_without_async_override() [Fact] public async Task ExecuteAsync_allows_sync_and_async_policies_to_coexist_without_ambiguous_ordering() { - var engine = CreateEngine(); + var engine = MutationEngineTestHost.CreateEngine(); var observed = new List(); engine.RegisterPolicy(new ObservedSyncAllowPolicy(observed)); engine.RegisterPolicy(new ObservedAsyncAllowPolicy(observed)); - var result = await engine.ExecuteAsync(new SampleMutation(), new SampleState("initial")); + var result = await engine.ExecuteAsync(new PolicySampleMutation(), new PolicySampleState("initial")); Assert.True(result.IsSuccess); Assert.Equal(["async", "sync"], observed); } - - private static IMutationEngine CreateEngine(Action? configure = null) - { - var services = new ServiceCollection(); - services.AddMutators(configure: configure); - - var provider = services.BuildServiceProvider(); - return provider.GetRequiredService(); - } - - private sealed record SampleState(string Value); - - private sealed class SampleMutation : MutationBase - { - public SampleMutation() - : base( - CreateIntent( - operationName: "UpdateSample", - category: "Test", - description: "Exercise policy evaluation"), - MutationContext.System("Policy test") with { StateId = "sample-1" }) - { - } - - public override MutationResult Apply(SampleState state) - => Success(state with { Value = "updated" }, StateChange.Modified("Value", state.Value, "updated")); - } - - private sealed class SyncAllowPolicy : IMutationPolicy - { - public string Name => "SyncAllow"; - public int Priority => 10; - public string? Description => "Simple synchronous allow policy."; - - public PolicyDecision Evaluate(IMutation mutation, SampleState state) - => PolicyDecision.Allow(Name, "Synchronous policy allowed the mutation."); - } - - private sealed class ObservedSyncAllowPolicy(List observed) : IMutationPolicy - { - public string Name => "ObservedSyncAllow"; - public int Priority => 10; - public string? Description => "Records synchronous policy evaluation order."; - - public PolicyDecision Evaluate(IMutation mutation, SampleState state) - { - observed.Add("sync"); - return PolicyDecision.Allow(Name, "Synchronous policy allowed the mutation."); - } - } - - private sealed class ObservedAsyncAllowPolicy(List observed) : IMutationPolicy - { - public string Name => "ObservedAsyncAllow"; - public int Priority => 100; - public string? Description => "Records asynchronous policy evaluation order."; - - public async Task EvaluateAsync( - IMutation mutation, - SampleState state, - CancellationToken cancellationToken = default) - { - await Task.Delay(10, cancellationToken); - observed.Add("async"); - return PolicyDecision.Allow(Name, "Asynchronous policy allowed the mutation."); - } - } - - private sealed class AsyncBlockingPolicy : IMutationPolicy - { - public string Name => "AsyncBlocking"; - public int Priority => 100; - public string? Description => "Simulates an external compliance check."; - - public async Task EvaluateAsync( - IMutation mutation, - SampleState state, - CancellationToken cancellationToken = default) - { - await Task.Delay(10, cancellationToken); - return PolicyDecision.Deny("External compliance check rejected the mutation.", Name); - } - } - - private sealed class SlowAsyncPolicy : IMutationPolicy - { - public string Name => "SlowExternalCheck"; - public int Priority => 100; - public string? Description => "Simulates a slow external dependency."; - - public async Task EvaluateAsync( - IMutation mutation, - SampleState state, - CancellationToken cancellationToken = default) - { - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); - return PolicyDecision.Allow(Name, "Finished too late."); - } - } - - private sealed class FailingAsyncPolicy : IMutationPolicy - { - public string Name => "FailingExternalCheck"; - public int Priority => 100; - public string? Description => "Simulates an external dependency failure."; - - public Task EvaluateAsync( - IMutation mutation, - SampleState state, - CancellationToken cancellationToken = default) - => throw new InvalidOperationException("Remote ticketing system unavailable."); - } - - private sealed class CancelAwareAsyncPolicy : IMutationPolicy - { - public string Name => "CancelAware"; - public int Priority => 100; - public string? Description => "Waits for cancellation."; - - public async Task EvaluateAsync( - IMutation mutation, - SampleState state, - CancellationToken cancellationToken = default) - { - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); - return PolicyDecision.Allow(Name, "Completed."); - } - } } diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Host/MutationEngineTestHost.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Host/MutationEngineTestHost.cs new file mode 100644 index 0000000..0791fac --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Host/MutationEngineTestHost.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; +using ModularityKit.Mutator.Abstractions; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Runtime; + +namespace ModularityKit.Mutator.Tests.TestSupport.Engine.Host; + +/// +/// Creates configured mutation engine instances for runtime engine tests. +/// +internal static class MutationEngineTestHost +{ + /// + /// Builds mutation engine with optional test-specific runtime configuration. + /// + /// Optional runtime configuration callback. + /// Configured mutation engine instance. + public static IMutationEngine CreateEngine(Action? configure = null) + { + var services = new ServiceCollection(); + services.AddMutators(configure: configure); + + var provider = services.BuildServiceProvider(); + return provider.GetRequiredService(); + } +} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/AsyncBlockingPolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/AsyncBlockingPolicy.cs new file mode 100644 index 0000000..44471ac --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/AsyncBlockingPolicy.cs @@ -0,0 +1,24 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Engine.Policy; + +/// +/// Asynchronous deny policy that simulates external compliance rejection. +/// +internal sealed class AsyncBlockingPolicy : IMutationPolicy +{ + public string Name => "AsyncBlocking"; + public int Priority => 100; + public string? Description => "Simulates an external compliance check."; + + public async Task EvaluateAsync( + IMutation mutation, + PolicySampleState state, + CancellationToken cancellationToken = default) + { + await Task.Delay(10, cancellationToken); + return PolicyDecision.Deny("External compliance check rejected the mutation.", Name); + } +} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/CancelAwareAsyncPolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/CancelAwareAsyncPolicy.cs new file mode 100644 index 0000000..192f505 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/CancelAwareAsyncPolicy.cs @@ -0,0 +1,24 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Engine.Policy; + +/// +/// Asynchronous policy that waits long enough for caller cancellation to trigger. +/// +internal sealed class CancelAwareAsyncPolicy : IMutationPolicy +{ + public string Name => "CancelAware"; + public int Priority => 100; + public string? Description => "Waits for cancellation."; + + public async Task EvaluateAsync( + IMutation mutation, + PolicySampleState state, + CancellationToken cancellationToken = default) + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + return PolicyDecision.Allow(Name, "Completed."); + } +} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/FailingAsyncPolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/FailingAsyncPolicy.cs new file mode 100644 index 0000000..9a19ff9 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/FailingAsyncPolicy.cs @@ -0,0 +1,21 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Engine.Policy; + +/// +/// Asynchronous policy that throws to exercise failure wrapping behavior. +/// +internal sealed class FailingAsyncPolicy : IMutationPolicy +{ + public string Name => "FailingExternalCheck"; + public int Priority => 100; + public string? Description => "Simulates an external dependency failure."; + + public Task EvaluateAsync( + IMutation mutation, + PolicySampleState state, + CancellationToken cancellationToken = default) + => throw new InvalidOperationException("Remote ticketing system unavailable."); +} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/ObservedAsyncAllowPolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/ObservedAsyncAllowPolicy.cs new file mode 100644 index 0000000..16fc600 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/ObservedAsyncAllowPolicy.cs @@ -0,0 +1,25 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Engine.Policy; + +/// +/// Asynchronous allow policy that records evaluation order. +/// +internal sealed class ObservedAsyncAllowPolicy(List observed) : IMutationPolicy +{ + public string Name => "ObservedAsyncAllow"; + public int Priority => 100; + public string? Description => "Records asynchronous policy evaluation order."; + + public async Task EvaluateAsync( + IMutation mutation, + PolicySampleState state, + CancellationToken cancellationToken = default) + { + await Task.Delay(10, cancellationToken); + observed.Add("async"); + return PolicyDecision.Allow(Name, "Asynchronous policy allowed the mutation."); + } +} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/ObservedSyncAllowPolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/ObservedSyncAllowPolicy.cs new file mode 100644 index 0000000..22ce47d --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/ObservedSyncAllowPolicy.cs @@ -0,0 +1,21 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Engine.Policy; + +/// +/// Synchronous allow policy that records evaluation order. +/// +internal sealed class ObservedSyncAllowPolicy(List observed) : IMutationPolicy +{ + public string Name => "ObservedSyncAllow"; + public int Priority => 10; + public string? Description => "Records synchronous policy evaluation order."; + + public PolicyDecision Evaluate(IMutation mutation, PolicySampleState state) + { + observed.Add("sync"); + return PolicyDecision.Allow(Name, "Synchronous policy allowed the mutation."); + } +} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/SlowAsyncPolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/SlowAsyncPolicy.cs new file mode 100644 index 0000000..21f8a44 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/SlowAsyncPolicy.cs @@ -0,0 +1,24 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Engine.Policy; + +/// +/// Asynchronous policy that exceeds the configured timeout window. +/// +internal sealed class SlowAsyncPolicy : IMutationPolicy +{ + public string Name => "SlowExternalCheck"; + public int Priority => 100; + public string? Description => "Simulates a slow external dependency."; + + public async Task EvaluateAsync( + IMutation mutation, + PolicySampleState state, + CancellationToken cancellationToken = default) + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + return PolicyDecision.Allow(Name, "Finished too late."); + } +} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/SyncAllowPolicy.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/SyncAllowPolicy.cs new file mode 100644 index 0000000..36f7255 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Policy/SyncAllowPolicy.cs @@ -0,0 +1,18 @@ +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Engine.Policy; + +/// +/// Synchronous allow policy used by policy evaluation tests. +/// +internal sealed class SyncAllowPolicy : IMutationPolicy +{ + public string Name => "SyncAllow"; + public int Priority => 10; + public string? Description => "Simple synchronous allow policy."; + + public PolicyDecision Evaluate(IMutation mutation, PolicySampleState state) + => PolicyDecision.Allow(Name, "Synchronous policy allowed the mutation."); +} diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Samples/PolicySampleState.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Samples/PolicySampleState.cs new file mode 100644 index 0000000..bf27775 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Engine/Samples/PolicySampleState.cs @@ -0,0 +1,6 @@ +namespace ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +/// +/// Sample state used by mutation engine policy evaluation tests. +/// +internal sealed record PolicySampleState(string Value); diff --git a/Tests/ModularityKit.Mutator.Tests/TestSupport/Mutations/PolicySampleMutation.cs b/Tests/ModularityKit.Mutator.Tests/TestSupport/Mutations/PolicySampleMutation.cs new file mode 100644 index 0000000..c392cd1 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/TestSupport/Mutations/PolicySampleMutation.cs @@ -0,0 +1,27 @@ +using ModularityKit.Mutator.Abstractions.Changes; +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Tests.TestSupport.Engine.Samples; + +namespace ModularityKit.Mutator.Tests.TestSupport.Mutations; + +/// +/// Sample mutation used to exercise policy evaluation paths. +/// +internal sealed class PolicySampleMutation : MutationBase +{ + public PolicySampleMutation() + : base( + CreateIntent( + operationName: "UpdateSample", + category: "Test", + description: "Exercise policy evaluation"), + MutationContext.System("Policy test") with { StateId = "sample-1" }) + { + } + + public override MutationResult Apply(PolicySampleState state) + => Success(state with { Value = "updated" }, + StateChange.Modified("Value", state.Value, "updated")); +} From 457e5dcf3f9a359894c372b26ec788aa14885abd Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 19:35:45 +0200 Subject: [PATCH 07/17] Extract typed side effect test payload --- .../Effects/SideEffectTypedDataTests.cs | 14 ++------------ .../Effects/WorkflowStartedSideEffectData.cs | 13 +++++++++++++ 2 files changed, 15 insertions(+), 12 deletions(-) create mode 100644 Tests/ModularityKit.Mutator.Tests/Effects/WorkflowStartedSideEffectData.cs diff --git a/Tests/ModularityKit.Mutator.Tests/Effects/SideEffectTypedDataTests.cs b/Tests/ModularityKit.Mutator.Tests/Effects/SideEffectTypedDataTests.cs index 7479df9..1e2f1f9 100644 --- a/Tests/ModularityKit.Mutator.Tests/Effects/SideEffectTypedDataTests.cs +++ b/Tests/ModularityKit.Mutator.Tests/Effects/SideEffectTypedDataTests.cs @@ -4,7 +4,7 @@ namespace ModularityKit.Mutator.Tests.Effects; -public sealed class SideEffectTypedDataTests +public sealed partial class SideEffectTypedDataTests { [Fact] public void Create_with_typed_payload_populates_contract_metadata() @@ -72,19 +72,9 @@ public void Json_roundtrip_without_registration_preserves_contract_and_payload_s Assert.Equal("workflow.started.unregistered", roundtrip!.DataContractType); Assert.Equal(1, roundtrip.DataContractVersion); - var payload = Assert.IsAssignableFrom>(roundtrip.Data); + var payload = Assert.IsType>(roundtrip.Data, exactMatch: false); Assert.Equal("alice", payload["Initiator"]); Assert.Equal(2L, payload["StepCount"]); Assert.Equal("wf-42", payload["WorkflowId"]); } - - [SideEffectDataContract("workflow.started", 1)] - private sealed record WorkflowStartedSideEffectData - { - public required string Initiator { get; init; } - - public required int StepCount { get; init; } - - public required string WorkflowId { get; init; } - } } diff --git a/Tests/ModularityKit.Mutator.Tests/Effects/WorkflowStartedSideEffectData.cs b/Tests/ModularityKit.Mutator.Tests/Effects/WorkflowStartedSideEffectData.cs new file mode 100644 index 0000000..8ea6897 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/Effects/WorkflowStartedSideEffectData.cs @@ -0,0 +1,13 @@ +using ModularityKit.Mutator.Abstractions.Effects; + +namespace ModularityKit.Mutator.Tests.Effects; + +[SideEffectDataContract("workflow.started", 1)] +public sealed class WorkflowStartedSideEffectData +{ + public required string Initiator { get; init; } + + public required int StepCount { get; init; } + + public required string WorkflowId { get; init; } +} From ac4acb36772a55e02ace66d9b593ef4e692cd3d3 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 19:36:17 +0200 Subject: [PATCH 08/17] Document governed execution compensation model --- ...4_Governed_Execution_Compensation_Model.md | 92 +++++++++++++++++++ Docs/Decision/listadr.md | 1 + 2 files changed, 93 insertions(+) create mode 100644 Docs/Decision/Adr/ADR_034_Governed_Execution_Compensation_Model.md diff --git a/Docs/Decision/Adr/ADR_034_Governed_Execution_Compensation_Model.md b/Docs/Decision/Adr/ADR_034_Governed_Execution_Compensation_Model.md new file mode 100644 index 0000000..a25795c --- /dev/null +++ b/Docs/Decision/Adr/ADR_034_Governed_Execution_Compensation_Model.md @@ -0,0 +1,92 @@ +# ADR-034: Governed Execution Compensation Model + +## Tag +#adr_034 + +## Status +Accepted + +## Date +2026-06-30 + +## Scope +ModularityKit.Mutator.Governance + +## Context + +ADR-027 established governed execution as the point where approved governance requests enter the core mutation engine. + +That execution path already persisted terminal request outcomes and already carried audit and history metadata, but it still lacked a first-class way to represent compensation: + +- `MutationIntent.IsReversible` existed only as intent metadata +- rollback or corrective compensation had no durable request-level model +- original and compensating executions were not explicitly linked +- audit and history could show that a mutation happened, but not that a later governed execution compensated for it + +Without an explicit compensation model, governed execution would treat rollback and forward correction as informal follow-up work. That is not sufficient once governance requests, request history, and durable execution outcomes become part of the public platform model. + +## Decision + +Governed compensation should be modeled as a first-class governed execution concern. + +The model should introduce: + +- explicit compensation plan contracts +- explicit execution classification for standard and compensating governed executions +- explicit links between original and compensating request records +- explicit request decision history for compensation behavior +- explicit audit and history metadata that preserves compensation semantics + +Implemented shape: + +- `GovernedCompensationPlan` describes the original request being compensated together with compensation kind, trigger, optional batch identity, and related request identifiers +- `GovernedCompensationKind` distinguishes restoration-style rollback from forward corrective compensation +- `GovernedCompensationTrigger` distinguishes operator-driven rollback from batch or failed-execution initiated compensation +- `GovernedExecutionKind` classifies governed execution as standard execution or compensation +- `GovernedExecutionLink` and `GovernedExecutionLinkType` link governed requests through `Compensates` and `CompensatedBy` +- request-level execution metadata is grouped under `GovernedExecutionDetails` +- compensation requests are created through dedicated request factory flow rather than being mixed into the baseline request factory path +- original requests receive a `Compensated` lifecycle decision when a compensating request executes successfully + +Runtime behavior: + +- compensation remains governed execution, not a utility outside the governance lifecycle +- successful compensating execution updates the compensating request and then links it back to the original request +- audit and history metadata must carry both execution kind and compensation metadata so the compensating behavior remains visible after persistence + +## Design Rationale + +- Compensation is a governance concern because it changes how durable requests, decisions, and execution outcomes are interpreted over time. +- Explicit links are preferable to inference from tags or metadata because they survive storage, querying, and future provider implementations more cleanly. +- Rollback and forward correction are not the same operational action, so the model should distinguish them even if the first tested path focuses on rollback. +- A dedicated compensation request factory keeps baseline request creation simpler while allowing compensation-specific semantics to remain explicit. +- Audit and history already form part of the governed execution contract, so compensation semantics should extend those flows rather than introducing a parallel reporting mechanism. + +## Consequences + +### Positive + +- Governed execution now has durable compensation semantics instead of relying on convention. +- Original and compensating requests can be traversed explicitly. +- Audit, history, and request decision flows preserve why and how compensation happened. +- The model supports operator-driven rollback immediately and leaves room for broader compensation planning later. + +### Negative + +- The governance request model becomes more complex because execution metadata now carries compensation and linking semantics. +- Runtime execution must perform an additional persistence step to link successful compensation back to the original request. +- Future storage providers and query models must preserve the new compensation and linking fields consistently. + +## Non-Goals + +- automatic generation of reverse mutations +- distributed saga orchestration +- full batch compensation engine +- replacing explicit domain modeling for inherently irreversible mutations + +## Related ADRs + +- ADR-020: Governance MutationRequest Model +- ADR-022: Governance Request Decisions and Storage +- ADR-023: Governance Versioned Request Resolution +- ADR-027: Governed Execution Manager diff --git a/Docs/Decision/listadr.md b/Docs/Decision/listadr.md index 5c089ae..9629334 100644 --- a/Docs/Decision/listadr.md +++ b/Docs/Decision/listadr.md @@ -49,5 +49,6 @@ These ADRs describe the `ModularityKit.Mutator.Governance` extension layer and i | 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) | +| ADR-034 | Governed Execution Compensation Model | [ADR-034](Adr/ADR_034_Governed_Execution_Compensation_Model.md) | > See individual ADRs for detailed context, decision rationale, and consequences. From 5b2161e12d735ed01caa3450576c81932fc4a891 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 19:56:11 +0200 Subject: [PATCH 09/17] Nest lifecycle fields into MutationRequest.Lifecycle --- ...MutationRequestApprovalDecisionExecutor.cs | 19 +++++++++--- ...tationRequestApprovalExpirationExecutor.cs | 9 ++++-- .../MutationRequestLifecycleManager.cs | 12 +++++-- .../MutationRequestTransitionExecutor.cs | 7 +++-- .../State/MutationRequestLifecycleState.cs | 7 +++-- .../MutationRequestVersionResolutionState.cs | 31 ++++++++++++------- 6 files changed, 59 insertions(+), 26 deletions(-) diff --git a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs index 6b1d1ca..a91cfff 100644 --- a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs +++ b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs @@ -156,23 +156,32 @@ private static MutationRequest BuildUpdatedRequest( updatedRequest = updatedRequest with { - Status = isFullyApproved ? MutationRequestStatus.Approved : MutationRequestStatus.Pending, - PendingReason = isFullyApproved ? null : PendingMutationReason.Approval + Lifecycle = request.Lifecycle with + { + Status = isFullyApproved ? MutationRequestStatus.Approved : MutationRequestStatus.Pending, + PendingReason = isFullyApproved ? null : PendingMutationReason.Approval + } }; } else { updatedRequest = updatedRequest with { - Status = MutationRequestStatus.Rejected, - PendingReason = null + Lifecycle = request.Lifecycle with + { + Status = MutationRequestStatus.Rejected, + PendingReason = null + } }; } return updatedRequest with { Decisions = decisions, - UpdatedAt = decisions[^1].Timestamp + Lifecycle = updatedRequest.Lifecycle with + { + UpdatedAt = decisions[^1].Timestamp + } }; } diff --git a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs index 104cd53..73c142a 100644 --- a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs +++ b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs @@ -67,11 +67,14 @@ requirement.ExpiresAt is not null && var updatedRequest = request with { - Status = MutationRequestStatus.Rejected, - PendingReason = null, + Lifecycle = request.Lifecycle with + { + Status = MutationRequestStatus.Rejected, + PendingReason = null, + UpdatedAt = decisions[^1].Timestamp + }, ApprovalRequirements = updatedRequirements, Decisions = decisions, - UpdatedAt = decisions[^1].Timestamp }; expiredRequests.Add(await _persistence.Persist(request, updatedRequest, cancellationToken).ConfigureAwait(false)); diff --git a/src/Governance/Runtime/Lifecycle/Execution/MutationRequestLifecycleManager.cs b/src/Governance/Runtime/Lifecycle/Execution/MutationRequestLifecycleManager.cs index e7f0e33..63ed526 100644 --- a/src/Governance/Runtime/Lifecycle/Execution/MutationRequestLifecycleManager.cs +++ b/src/Governance/Runtime/Lifecycle/Execution/MutationRequestLifecycleManager.cs @@ -42,8 +42,11 @@ public Task MoveToPending( reason, request => request with { - PendingReason = pendingReason, - ExpiresAt = expiresAt + Lifecycle = request.Lifecycle with + { + PendingReason = pendingReason, + ExpiresAt = expiresAt + } }, metadata, cancellationToken); @@ -64,7 +67,10 @@ public Task Approve( reason, request => request with { - PendingReason = null + Lifecycle = request.Lifecycle with + { + PendingReason = null + } }, metadata, cancellationToken); diff --git a/src/Governance/Runtime/Lifecycle/Execution/MutationRequestTransitionExecutor.cs b/src/Governance/Runtime/Lifecycle/Execution/MutationRequestTransitionExecutor.cs index eba88b6..050a0bc 100644 --- a/src/Governance/Runtime/Lifecycle/Execution/MutationRequestTransitionExecutor.cs +++ b/src/Governance/Runtime/Lifecycle/Execution/MutationRequestTransitionExecutor.cs @@ -48,8 +48,11 @@ public async Task Execute( var updatedRequest = applyState(request) with { - Status = targetStatus, - UpdatedAt = decision.Timestamp, + Lifecycle = request.Lifecycle with + { + Status = targetStatus, + UpdatedAt = decision.Timestamp + }, Decisions = [.. request.Decisions, decision] }; diff --git a/src/Governance/Runtime/Lifecycle/State/MutationRequestLifecycleState.cs b/src/Governance/Runtime/Lifecycle/State/MutationRequestLifecycleState.cs index 46a40d3..3f53886 100644 --- a/src/Governance/Runtime/Lifecycle/State/MutationRequestLifecycleState.cs +++ b/src/Governance/Runtime/Lifecycle/State/MutationRequestLifecycleState.cs @@ -14,8 +14,11 @@ public static MutationRequest ClearPendingState(MutationRequest request) { return request with { - PendingReason = null, - ExpiresAt = null + Lifecycle = request.Lifecycle with + { + PendingReason = null, + ExpiresAt = null + } }; } diff --git a/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs index e1fa044..75c3b55 100644 --- a/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs +++ b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionState.cs @@ -32,9 +32,12 @@ request with public static MutationRequest ApplyRejectedAsStale(MutationRequest request, string currentStateVersion, MutationRequestDecision decision) => AppendDecision( request with { - Status = MutationRequestStatus.Rejected, - PendingReason = null, - UpdatedAt = decision.Timestamp + Lifecycle = request.Lifecycle with + { + Status = MutationRequestStatus.Rejected, + PendingReason = null, + UpdatedAt = decision.Timestamp + } }, decision); /// @@ -50,13 +53,16 @@ public static MutationRequest ApplyRenewedApprovalRequired( MutationRequestDecision decision) => AppendDecision( request with { - Status = MutationRequestStatus.Pending, - PendingReason = PendingMutationReason.Approval, + Lifecycle = request.Lifecycle with + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval, + UpdatedAt = decision.Timestamp + }, Versioning = request.Versioning with { ExpectedStateVersion = currentStateVersion - }, - UpdatedAt = decision.Timestamp + } }, decision); @@ -73,13 +79,16 @@ public static MutationRequest ApplyRevalidationRequired( MutationRequestDecision decision) => AppendDecision( request with { - Status = MutationRequestStatus.Pending, - PendingReason = PendingMutationReason.Revalidation, + Lifecycle = request.Lifecycle with + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Revalidation, + UpdatedAt = decision.Timestamp + }, Versioning = request.Versioning with { ExpectedStateVersion = currentStateVersion - }, - UpdatedAt = decision.Timestamp + } }, decision); From 593450e9b01d15f3b10d6e418fcf9f1347a45343 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 19:57:09 +0200 Subject: [PATCH 10/17] Move lifecycle properties into Lifecycle object --- src/Governance/README.md | 2 +- .../GovernanceExecutionManager.cs | 5 ++++- .../Outcome/GovernedExecutionOutcomeHandler.cs | 18 ++++++++++++------ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/Governance/README.md b/src/Governance/README.md index 23dbc34..5455a74 100644 --- a/src/Governance/README.md +++ b/src/Governance/README.md @@ -31,7 +31,7 @@ var request = MutationRequestFactory.PendingApproval( ]); var persisted = await store.Create(request); -Console.WriteLine($"{persisted.RequestId} -> {persisted.Status}"); +Console.WriteLine($"{persisted.RequestId} -> {persisted.Lifecycle.Status}"); ``` ## Primary APIs diff --git a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs index d756804..3731648 100644 --- a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs +++ b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs @@ -219,7 +219,10 @@ private async Task LinkCompensationExecution( var updatedOriginalRequest = originalRequest with { - UpdatedAt = linkedAt, + Lifecycle = originalRequest.Lifecycle with + { + UpdatedAt = linkedAt + }, Execution = originalRequest.Execution with { RelatedExecutions = diff --git a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs index 4ab1e51..1174463 100644 --- a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs +++ b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs @@ -60,9 +60,12 @@ public async Task PersistRejectedExecution( var rejectedRequest = request with { - Status = MutationRequestStatus.Rejected, - PendingReason = null, - UpdatedAt = decision.Timestamp, + Lifecycle = request.Lifecycle with + { + Status = MutationRequestStatus.Rejected, + PendingReason = null, + UpdatedAt = decision.Timestamp + }, Decisions = [.. request.Decisions, decision], SideEffects = sideEffects ?? [] }; @@ -95,15 +98,18 @@ public async Task PersistExecutedRequest( var executedRequest = request with { - Status = MutationRequestStatus.Executed, - PendingReason = null, + Lifecycle = request.Lifecycle with + { + Status = MutationRequestStatus.Executed, + PendingReason = null, + UpdatedAt = decision.Timestamp + }, Versioning = request.Versioning with { ExpectedStateVersion = resultingStateVersion, ResultingStateVersion = resultingStateVersion, ExecutedAt = decision.Timestamp }, - UpdatedAt = decision.Timestamp, Decisions = [.. request.Decisions, decision], SideEffects = mutationResult.SideEffects.ToList() }; From 5824a19aba8fa17a37dc8e7ac0ae7617d787d445 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 19:59:12 +0200 Subject: [PATCH 11/17] Update examples to new request model --- .../Scenarios/GovernanceApprovalWorkflowScenario.cs | 4 ++-- .../Scenarios/GovernanceExecutionScenario.cs | 4 ++-- .../Queries/Scenarios/GovernanceQueriesSampleData.cs | 4 ++-- .../Scenarios/GovernanceRedisQueriesScenario.cs | 4 ++-- .../Scenarios/GovernanceRequestLifecycleScenario.cs | 10 +++++----- Examples/Governance/VersionedResolution/README.md | 2 +- .../Scenarios/GovernanceVersionedResolutionScenario.cs | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs b/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs index abdef49..8e8c8f2 100644 --- a/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs +++ b/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs @@ -151,8 +151,8 @@ private static void PrintSection(string title) private static void PrintRequest(MutationRequest request) { - Console.WriteLine($"Request status: {request.Status}"); - Console.WriteLine($"Pending reason: {request.PendingReason?.ToString() ?? "-"}"); + Console.WriteLine($"Request status: {request.Lifecycle.Status}"); + Console.WriteLine($"Pending reason: {request.Lifecycle.PendingReason?.ToString() ?? "-"}"); Console.WriteLine($"Revision: {request.Revision}"); Console.WriteLine("Approval requirements:"); diff --git a/Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs b/Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs index 0e71fd2..08fccda 100644 --- a/Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs +++ b/Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs @@ -41,7 +41,7 @@ public static async Task Run() expectedStateVersion: "v10")); var currentState = new FeatureFlagState( - request.StateId, + request.Scope.StateId, IsEnabled: false, Version: "v10"); @@ -55,7 +55,7 @@ public static async Task Run() Console.WriteLine("=== Governed Execution ==="); Console.WriteLine($"Executed: {execution.WasExecuted}"); Console.WriteLine($"Resolution: {execution.Resolution.Outcome}"); - Console.WriteLine($"Request status: {execution.Request.Status}"); + Console.WriteLine($"Request status: {execution.Request.Lifecycle.Status}"); Console.WriteLine($"Resulting version: {execution.ResultingStateVersion ?? "-"}"); Console.WriteLine($"Last decision: {execution.Request.Decisions[^1].Type}"); Console.WriteLine($"Reason: {execution.Request.Decisions[^1].Reason}"); diff --git a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs index dd4ac38..fa57e72 100644 --- a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs +++ b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs @@ -72,7 +72,7 @@ public static void PrintRequests(IReadOnlyList requests) foreach (var request in requests) { Console.WriteLine( - $"- {request.RequestId} | {request.StateId} | {request.Intent.Category} | {request.Status} | pending: {request.PendingReason?.ToString() ?? "-"}"); + $"- {request.RequestId} | {request.Scope.StateId} | {request.Payload.Intent.Category} | {request.Lifecycle.Status} | pending: {request.Lifecycle.PendingReason?.ToString() ?? "-"}"); } if (requests.Count == 0) @@ -84,7 +84,7 @@ public static void PrintApprovals(IReadOnlyList approvals) foreach (var approval in approvals) { Console.WriteLine( - $"- {approval.Request.RequestId} | {approval.Request.Intent.Category} | approver: {approval.Approval.ApproverId} | status: {approval.Approval.Status}"); + $"- {approval.Request.RequestId} | {approval.Request.Payload.Intent.Category} | approver: {approval.Approval.ApproverId} | status: {approval.Approval.Status}"); } if (approvals.Count == 0) diff --git a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs index 20487b4..299b0b4 100644 --- a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs +++ b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs @@ -217,7 +217,7 @@ private static void PrintRequests(IReadOnlyList requests) foreach (var request in requests) { Console.WriteLine( - $"- {request.RequestId} | {request.StateId} | {request.Intent.Category} | {request.Status} | pending: {request.PendingReason?.ToString() ?? "-"}"); + $"- {request.RequestId} | {request.Scope.StateId} | {request.Payload.Intent.Category} | {request.Lifecycle.Status} | pending: {request.Lifecycle.PendingReason?.ToString() ?? "-"}"); } if (requests.Count == 0) @@ -229,7 +229,7 @@ private static void PrintApprovals(IReadOnlyList approvals foreach (var approval in approvals) { Console.WriteLine( - $"- {approval.Request.RequestId} | {approval.Request.Intent.Category} | approver: {approval.Approval.ApproverId} | status: {approval.Approval.Status}"); + $"- {approval.Request.RequestId} | {approval.Request.Payload.Intent.Category} | approver: {approval.Approval.ApproverId} | status: {approval.Approval.Status}"); } if (approvals.Count == 0) diff --git a/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs b/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs index 3ffe98c..99fc8a8 100644 --- a/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs +++ b/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs @@ -119,7 +119,7 @@ private static void PrintRequests(IReadOnlyList requests) foreach (var request in requests) { Console.WriteLine( - $"- {request.RequestId} | {request.StateId} | {request.Status} | pending: {request.PendingReason?.ToString() ?? "-"}"); + $"- {request.RequestId} | {request.Scope.StateId} | {request.Lifecycle.Status} | pending: {request.Lifecycle.PendingReason?.ToString() ?? "-"}"); } if (requests.Count == 0) @@ -129,10 +129,10 @@ private static void PrintRequests(IReadOnlyList requests) private static void PrintRequestDetails(MutationRequest request) { Console.WriteLine($"{request.RequestId}"); - Console.WriteLine($" state: {request.StateId}"); - Console.WriteLine($" status: {request.Status}"); - Console.WriteLine($" pending: {request.PendingReason?.ToString() ?? "-"}"); - Console.WriteLine($" expires: {request.ExpiresAt?.ToString("O") ?? "-"}"); + Console.WriteLine($" state: {request.Scope.StateId}"); + Console.WriteLine($" status: {request.Lifecycle.Status}"); + Console.WriteLine($" pending: {request.Lifecycle.PendingReason?.ToString() ?? "-"}"); + Console.WriteLine($" expires: {request.Lifecycle.ExpiresAt?.ToString(\"O\") ?? \"-\"}"); Console.WriteLine(" decisions:"); foreach (var decision in request.Decisions) diff --git a/Examples/Governance/VersionedResolution/README.md b/Examples/Governance/VersionedResolution/README.md index c0849b5..73cc4a7 100644 --- a/Examples/Governance/VersionedResolution/README.md +++ b/Examples/Governance/VersionedResolution/README.md @@ -57,7 +57,7 @@ var resolution = await manager.ResolveAndStore( strategy: VersionedRequestResolutionStrategy.RejectStale); Console.WriteLine(resolution.Outcome); -Console.WriteLine(resolution.Request.Status); +Console.WriteLine(resolution.Request.Lifecycle.Status); Console.WriteLine(resolution.Request.Decisions[^1].Type); ``` diff --git a/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs b/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs index 8017c94..4f1ee11 100644 --- a/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs +++ b/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs @@ -91,8 +91,8 @@ private static void PrintResolution(MutationRequestVersionResolution resolution) Console.WriteLine($"Was stale: {resolution.IsStale}"); Console.WriteLine($"Expected version: {resolution.ExpectedStateVersion ?? "-"}"); Console.WriteLine($"Current version: {resolution.CurrentStateVersion}"); - Console.WriteLine($"Request status: {resolution.Request.Status}"); - Console.WriteLine($"Next expected version: {resolution.Request.ExpectedStateVersion ?? "-"}"); + Console.WriteLine($"Request status: {resolution.Request.Lifecycle.Status}"); + Console.WriteLine($"Next expected version: {resolution.Request.Versioning.ExpectedStateVersion ?? "-"}"); Console.WriteLine($"Last decision: {decision.Type}"); Console.WriteLine($"Decision reason: {decision.Reason}"); } From 0829eba822d00bf11e2cc36a6162399f5907dd7f Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 20:01:22 +0200 Subject: [PATCH 12/17] Refactor MutationRequest with detail grouping --- .../Factory/MutationRequestFactory.cs | 71 +++++++++++------ .../Requests/Model/MutationRequest.cs | 76 +++++++++---------- .../Model/MutationRequestLifecycleDetails.cs | 34 +++++++++ .../Model/MutationRequestPayloadDetails.cs | 20 +++++ .../Model/MutationRequestScopeDetails.cs | 22 ++++++ 5 files changed, 161 insertions(+), 62 deletions(-) create mode 100644 src/Governance/Abstractions/Requests/Model/MutationRequestLifecycleDetails.cs create mode 100644 src/Governance/Abstractions/Requests/Model/MutationRequestPayloadDetails.cs create mode 100644 src/Governance/Abstractions/Requests/Model/MutationRequestScopeDetails.cs diff --git a/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs b/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs index 4062463..c29573b 100644 --- a/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs +++ b/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs @@ -78,19 +78,28 @@ public static MutationRequest Pending( { return new MutationRequest { - StateId = stateId, - StateType = stateType, - MutationType = mutationType, - Intent = intent, - Context = context, - Status = MutationRequestStatus.Pending, - PendingReason = pendingReason, + Scope = new MutationRequestScopeDetails + { + StateId = stateId, + StateType = stateType, + MutationType = mutationType + }, + Payload = new MutationRequestPayloadDetails + { + Intent = intent, + Context = context + }, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = pendingReason, + ExpiresAt = expiresAt + }, Requirements = requirements ?? [], Versioning = new MutationRequestVersioningDetails { ExpectedStateVersion = expectedStateVersion }, - ExpiresAt = expiresAt, Metadata = metadata ?? new Dictionary(), Decisions = [ @@ -171,20 +180,29 @@ public static MutationRequest PendingApproval( return new MutationRequest { - StateId = stateId, - StateType = stateType, - MutationType = mutationType, - Intent = intent, - Context = context, - Status = MutationRequestStatus.Pending, - PendingReason = PendingMutationReason.Approval, + Scope = new MutationRequestScopeDetails + { + StateId = stateId, + StateType = stateType, + MutationType = mutationType + }, + Payload = new MutationRequestPayloadDetails + { + Intent = intent, + Context = context + }, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval, + ExpiresAt = expiresAt + }, Requirements = requirements, ApprovalRequirements = approvalRequirements, Versioning = new MutationRequestVersioningDetails { ExpectedStateVersion = expectedStateVersion }, - ExpiresAt = expiresAt, Metadata = metadata ?? new Dictionary(), Decisions = [ @@ -257,12 +275,21 @@ public static MutationRequest Approved( { return new MutationRequest { - StateId = stateId, - StateType = stateType, - MutationType = mutationType, - Intent = intent, - Context = context, - Status = MutationRequestStatus.Approved, + Scope = new MutationRequestScopeDetails + { + StateId = stateId, + StateType = stateType, + MutationType = mutationType + }, + Payload = new MutationRequestPayloadDetails + { + Intent = intent, + Context = context + }, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Approved + }, Versioning = new MutationRequestVersioningDetails { ExpectedStateVersion = expectedStateVersion diff --git a/src/Governance/Abstractions/Requests/Model/MutationRequest.cs b/src/Governance/Abstractions/Requests/Model/MutationRequest.cs index 6d63ffa..7bd0e06 100644 --- a/src/Governance/Abstractions/Requests/Model/MutationRequest.cs +++ b/src/Governance/Abstractions/Requests/Model/MutationRequest.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Effects; using ModularityKit.Mutator.Abstractions.Intent; @@ -19,45 +20,25 @@ public sealed record MutationRequest public string RequestId { get; init; } = Guid.NewGuid().ToString(); /// - /// Identifier of the state targeted by this request. + /// Target scope details for the governed request. /// - public string StateId { get; init; } = string.Empty; + public MutationRequestScopeDetails Scope { get; init; } = new(); /// - /// Logical state type targeted by the request. + /// Submitted mutation payload details for the governed request. /// - public string StateType { get; init; } = string.Empty; + public MutationRequestPayloadDetails Payload { get; init; } = new(); /// - /// CLR type name of the underlying mutation. + /// Lifecycle state and lifecycle timestamps associated with the request. /// - public string MutationType { get; init; } = string.Empty; - - /// - /// Intent associated with the requested mutation. - /// - public MutationIntent Intent { get; init; } = null!; - - /// - /// Request context describing who requested the mutation and why. - /// - public MutationContext Context { get; init; } = null!; + public MutationRequestLifecycleDetails Lifecycle { get; init; } = new(); /// /// Governed execution-specific details associated with this request. /// public GovernedExecutionDetails Execution { get; init; } = new(); - /// - /// Current lifecycle status of the request. - /// - public MutationRequestStatus Status { get; init; } = MutationRequestStatus.Created; - - /// - /// Reason why the request is pending, if it has not executed yet. - /// - public PendingMutationReason? PendingReason { get; init; } - /// /// Requirements that must be fulfilled before execution may proceed. /// @@ -89,22 +70,37 @@ public sealed record MutationRequest public MutationRequestVersioningDetails Versioning { get; init; } = new(); /// - /// Optional expiration time for pending requests. + /// Additional governance metadata carried by the request. /// - public DateTimeOffset? ExpiresAt { get; init; } + public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); - /// - /// Timestamp when the request was first created. - /// - public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + [JsonIgnore] + public string StateId => Scope.StateId; - /// - /// Timestamp of the last lifecycle update applied to the request. - /// - public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow; + [JsonIgnore] + public string StateType => Scope.StateType; - /// - /// Additional governance metadata carried by the request. - /// - public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); + [JsonIgnore] + public string MutationType => Scope.MutationType; + + [JsonIgnore] + public MutationIntent Intent => Payload.Intent; + + [JsonIgnore] + public MutationContext Context => Payload.Context; + + [JsonIgnore] + public MutationRequestStatus Status => Lifecycle.Status; + + [JsonIgnore] + public PendingMutationReason? PendingReason => Lifecycle.PendingReason; + + [JsonIgnore] + public DateTimeOffset? ExpiresAt => Lifecycle.ExpiresAt; + + [JsonIgnore] + public DateTimeOffset CreatedAt => Lifecycle.CreatedAt; + + [JsonIgnore] + public DateTimeOffset UpdatedAt => Lifecycle.UpdatedAt; } diff --git a/src/Governance/Abstractions/Requests/Model/MutationRequestLifecycleDetails.cs b/src/Governance/Abstractions/Requests/Model/MutationRequestLifecycleDetails.cs new file mode 100644 index 0000000..55c6db6 --- /dev/null +++ b/src/Governance/Abstractions/Requests/Model/MutationRequestLifecycleDetails.cs @@ -0,0 +1,34 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +/// +/// Groups lifecycle state and lifecycle timestamps associated with a governed request. +/// +public sealed record MutationRequestLifecycleDetails +{ + /// + /// Current lifecycle status of the request. + /// + public MutationRequestStatus Status { get; init; } = MutationRequestStatus.Created; + + /// + /// Reason why the request is pending, if it has not executed yet. + /// + public PendingMutationReason? PendingReason { get; init; } + + /// + /// Optional expiration time for pending requests. + /// + public DateTimeOffset? ExpiresAt { get; init; } + + /// + /// Timestamp when the request was first created. + /// + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + + /// + /// Timestamp of the last lifecycle update applied to the request. + /// + public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow; +} diff --git a/src/Governance/Abstractions/Requests/Model/MutationRequestPayloadDetails.cs b/src/Governance/Abstractions/Requests/Model/MutationRequestPayloadDetails.cs new file mode 100644 index 0000000..8e347ea --- /dev/null +++ b/src/Governance/Abstractions/Requests/Model/MutationRequestPayloadDetails.cs @@ -0,0 +1,20 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Intent; + +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +/// +/// Groups the submitted mutation intent and requester context carried by a governed request. +/// +public sealed record MutationRequestPayloadDetails +{ + /// + /// Intent associated with the requested mutation. + /// + public MutationIntent Intent { get; init; } = null!; + + /// + /// Request context describing who requested the mutation and why. + /// + public MutationContext Context { get; init; } = null!; +} diff --git a/src/Governance/Abstractions/Requests/Model/MutationRequestScopeDetails.cs b/src/Governance/Abstractions/Requests/Model/MutationRequestScopeDetails.cs new file mode 100644 index 0000000..c9d8d96 --- /dev/null +++ b/src/Governance/Abstractions/Requests/Model/MutationRequestScopeDetails.cs @@ -0,0 +1,22 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +/// +/// Groups target scope identifiers for a governed mutation request. +/// +public sealed record MutationRequestScopeDetails +{ + /// + /// Identifier of the state targeted by this request. + /// + public string StateId { get; init; } = string.Empty; + + /// + /// Logical state type targeted by the request. + /// + public string StateType { get; init; } = string.Empty; + + /// + /// CLR type name of the underlying mutation. + /// + public string MutationType { get; init; } = string.Empty; +} From 943f2e94e135cd7d145856647e7e35ee59eca013 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 20:03:46 +0200 Subject: [PATCH 13/17] Update tests for refactored MutationRequest model --- .../Keys/RedisMutationRequestKeyspaceTests.cs | 16 +- .../RedisMutationRequestSerializerTests.cs | 12 +- .../GovernanceExecutionManagerTests.cs | 163 +++++++++++++++++- .../MutationRequestStoreContractTests.cs | 14 +- .../Queries/MutationRequestQueryStoreTests.cs | 73 +++++--- .../Requests/MutationRequestFactoryTests.cs | 2 +- ...equestVersionResolutionPersistenceTests.cs | 2 +- 7 files changed, 238 insertions(+), 44 deletions(-) diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceTests.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceTests.cs index 87441f6..8ce6a56 100644 --- a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceTests.cs @@ -49,11 +49,17 @@ public void Enumerate_indexes_includes_pending_indexes_only_for_pending_requests var request = new MutationRequest { RequestId = "req-42", - StateId = "tenant-42", - StateType = "IamRoleState", - MutationType = "GrantRoleMutation", - Status = MutationRequestStatus.Pending, - PendingReason = PendingMutationReason.Approval + Scope = new MutationRequestScopeDetails + { + StateId = "tenant-42", + StateType = "IamRoleState", + MutationType = "GrantRoleMutation" + }, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval + } }; var keys = keyspace.EnumerateIndexes(request).Select(key => key.ToString()).ToArray(); 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 827a491..e548bce 100644 --- a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs @@ -4,6 +4,7 @@ using ModularityKit.Mutator.Abstractions.Policies; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Redis.Serialization; using Xunit; @@ -61,8 +62,13 @@ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime() }) with { - CreatedAt = new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero), - UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero), + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval, + CreatedAt = new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero), + UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero) + }, SideEffects = [ SideEffect.Critical( @@ -95,6 +101,8 @@ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime() Assert.Single(roundtrip.ApprovalRequirements); Assert.Equal("security-lead", roundtrip.ApprovalRequirements[0].ApproverId); Assert.Equal(3, roundtrip.Decisions.Count); + Assert.Equal(request.Lifecycle.CreatedAt, roundtrip.Lifecycle.CreatedAt); + Assert.Equal(request.Lifecycle.UpdatedAt, roundtrip.Lifecycle.UpdatedAt); } [SideEffectDataContract("redis.governance.side-effect", 1)] diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs index 82b807f..b6c97f3 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs @@ -10,6 +10,8 @@ using ModularityKit.Mutator.Abstractions.Results; using ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts; using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Links; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; @@ -75,9 +77,9 @@ public async Task ExecuteApproved_executes_request_persists_resulting_version_an Assert.NotNull(result.MutationResult); Assert.Equal("v11", result.ResultingStateVersion); Assert.Equal(MutationRequestStatus.Executed, result.Request.Status); - Assert.Equal("v11", result.Request.ResultingStateVersion); - Assert.Equal("v11", result.Request.ExpectedStateVersion); - Assert.NotNull(result.Request.ExecutedAt); + Assert.Equal("v11", result.Request.Versioning.ResultingStateVersion); + Assert.Equal("v11", result.Request.Versioning.ExpectedStateVersion); + Assert.NotNull(result.Request.Versioning.ExecutedAt); Assert.Single(result.Request.SideEffects); Assert.Equal("RoleElevated", result.Request.SideEffects[0].Type); Assert.Equal("governance.execution-effect", result.Request.SideEffects[0].DataContractType); @@ -180,7 +182,7 @@ public async Task ExecuteApproved_requires_renewed_approval_before_execution_whe Assert.Null(result.MutationResult); Assert.Equal(MutationRequestStatus.Pending, result.Request.Status); Assert.Equal(PendingMutationReason.Approval, result.Request.PendingReason); - Assert.Equal("v15", result.Request.ExpectedStateVersion); + Assert.Equal("v15", result.Request.Versioning.ExpectedStateVersion); Assert.Equal(MutationRequestVersionResolutionOutcome.RequiresRenewedApproval, result.Resolution.Outcome); } @@ -223,8 +225,8 @@ public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_ Assert.Equal(MutationRequestVersionResolutionOutcome.RevalidateOnLatestState, result.Resolution.Outcome); Assert.Equal(MutationRequestStatus.Executed, result.Request.Status); Assert.Equal("v16", result.ResultingStateVersion); - Assert.Equal("v16", result.Request.ResultingStateVersion); - Assert.Equal("v16", result.Request.ExpectedStateVersion); + Assert.Equal("v16", result.Request.Versioning.ResultingStateVersion); + Assert.Equal("v16", result.Request.Versioning.ExpectedStateVersion); Assert.Equal( MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), result.Request.Decisions[^1].Type); @@ -233,6 +235,110 @@ public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_ decision => decision.Type == MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RevalidationRequired)); } + [Fact] + public async Task ExecuteApproved_executes_operator_rollback_compensation_and_links_execution_history() + { + var services = new ServiceCollection(); + services.AddMutators(MutationEngineOptions.Strict); + await using var provider = services.BuildServiceProvider(); + + var engine = provider.GetRequiredService(); + var auditor = provider.GetRequiredService(); + var historyStore = provider.GetRequiredService(); + var requestStore = new InMemoryMutationRequestStore(); + var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); + var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); + + var originalRequest = await requestStore.Create(MutationRequestFactory.Approved( + stateId: "tenant-42:roles", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated access", + IsReversible = true + }, + context: MutationContext.User("requester", "Requester", "Need access"), + expectedStateVersion: "v10")); + + var originalState = RoleState.Create("tenant-42:roles", role: "Reader", version: "v10"); + var originalMutation = new PromoteRoleMutation( + MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), + nextVersion: "v11"); + + var originalResult = await executionManager.ExecuteApproved( + originalRequest.RequestId, + originalMutation, + originalState, + governanceContext: MutationContext.Service("governance-runtime", "Execute approved request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); + + var compensationPlan = new GovernedCompensationPlan + { + OriginalRequestId = originalRequest.RequestId, + Kind = GovernedCompensationKind.Rollback, + Trigger = GovernedCompensationTrigger.OperatorRollback, + Reason = "Operator reverted the elevated role after incident review." + }; + + var compensationRequest = await requestStore.Create(CompensationMutationRequestFactory.Approved( + stateId: "tenant-42:roles", + intent: new MutationIntent + { + OperationName = "RollbackRole", + Category = "Security", + Description = "Restore the previous tenant role" + }, + context: MutationContext.User("operator-2", "Operator Two", "Rollback approved role mutation"), + compensation: compensationPlan, + expectedStateVersion: "v11")); + + var compensationMutation = new RollbackRoleMutation( + MutationContext.User("operator-2", "Operator Two", "Rollback approved role mutation"), + nextVersion: "v12"); + + var compensationResult = await executionManager.ExecuteApproved( + compensationRequest.RequestId, + compensationMutation, + originalResult.MutationResult!.NewState!, + governanceContext: MutationContext.Service("governance-runtime", "Execute operator rollback"), + strategy: VersionedRequestResolutionStrategy.RejectStale); + + Assert.True(compensationResult.WasExecuted); + Assert.Equal(GovernedExecutionKind.Compensation, compensationResult.ExecutionKind); + Assert.NotNull(compensationResult.Compensation); + Assert.Equal(originalRequest.RequestId, compensationResult.Compensation!.OriginalRequestId); + Assert.Equal(GovernedCompensationKind.Rollback, compensationResult.Compensation.Kind); + Assert.Contains( + compensationResult.Request.Execution.RelatedExecutions, + link => link.RequestId == originalRequest.RequestId && + link.Type == GovernedExecutionLinkType.Compensates); + + var compensatedOriginalRequest = await requestStore.Get(originalRequest.RequestId); + Assert.NotNull(compensatedOriginalRequest); + Assert.Contains( + compensatedOriginalRequest!.Execution.RelatedExecutions, + link => link.RequestId == compensationRequest.RequestId && + link.Type == GovernedExecutionLinkType.CompensatedBy && + link.ExecutionKind == GovernedExecutionKind.Compensation); + Assert.Contains( + compensatedOriginalRequest.Decisions, + decision => decision.Type == MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Compensated)); + + var auditEntries = await auditor.GetAuditLogAsync(originalRequest.StateId); + var history = await historyStore.GetHistoryAsync(originalRequest.StateId); + + Assert.Equal(2, auditEntries.Count); + Assert.Equal(2, history.Entries.Count); + Assert.Equal("Compensation", auditEntries[1].Context.Metadata["GovernanceExecutionKind"]); + Assert.Equal("Compensation", history.Entries[1].Context.Metadata["GovernanceExecutionKind"]); + + var auditCompensation = Assert.IsAssignableFrom>( + auditEntries[1].Context.Metadata["GovernanceCompensation"]); + Assert.Equal(originalRequest.RequestId, auditCompensation["OriginalRequestId"]); + Assert.Equal("Rollback", auditCompensation["Kind"]); + } + private sealed record RoleState(string StateId, string Role, string Version) : IVersionedState { public static RoleState Create(string stateId, string role, string version) => new(stateId, role, version); @@ -282,6 +388,51 @@ public ValidationResult Validate(RoleState state) public MutationResult Simulate(RoleState state) => Apply(state); } + private sealed class RollbackRoleMutation(MutationContext context, string nextVersion) : IMutation + { + public MutationIntent Intent { get; } = new() + { + OperationName = "RollbackRole", + Category = "Security", + Description = "Rollback tenant role to Reader", + IsReversible = false + }; + + public MutationContext Context { get; } = context; + + public MutationResult Apply(RoleState state) + { + var newState = state with + { + Role = "Reader", + Version = nextVersion + }; + + return MutationResult.Success( + newState, + ChangeSet.Single(StateChange.Modified("Role", state.Role, newState.Role)), + [ + SideEffect.Create( + type: "RoleRollback", + description: "Governed compensation restored the previous role", + data: new GovernanceExecutionSideEffectData + { + RequestStateId = state.StateId, + NewRole = newState.Role + }) + ]); + } + + public ValidationResult Validate(RoleState state) + { + return state.Role == "Reader" + ? ValidationResult.WithError("Role", "Role is already Reader.") + : ValidationResult.Success(); + } + + public MutationResult Simulate(RoleState state) => Apply(state); + } + [SideEffectDataContract("governance.execution-effect", 1)] private sealed record GovernanceExecutionSideEffectData { diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs index c5140a5..8d99ce6 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs @@ -34,8 +34,11 @@ public async Task TryStore_rejects_stale_revision_and_preserves_current_state() var firstUpdate = created with { - Status = MutationRequestStatus.Approved, - PendingReason = null, + Lifecycle = created.Lifecycle with + { + Status = MutationRequestStatus.Approved, + PendingReason = null + }, Decisions = [ .. created.Decisions, @@ -50,8 +53,11 @@ public async Task TryStore_rejects_stale_revision_and_preserves_current_state() var staleUpdate = created with { - Status = MutationRequestStatus.Canceled, - PendingReason = null, + Lifecycle = created.Lifecycle with + { + Status = MutationRequestStatus.Canceled, + PendingReason = null + }, Decisions = [ .. created.Decisions, diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs index c130006..5f76434 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs @@ -393,10 +393,13 @@ private static MutationRequest CreateSimpleRequest( with { RequestId = requestId, - Status = status, - PendingReason = pendingReason, - CreatedAt = createdAt, - UpdatedAt = createdAt + Lifecycle = new MutationRequestLifecycleDetails + { + Status = status, + PendingReason = pendingReason, + CreatedAt = createdAt, + UpdatedAt = createdAt + } }; private static MutationRequest CreateApprovedRequest( @@ -423,10 +426,13 @@ private static MutationRequest CreateApprovedRequest( with { RequestId = requestId, - Status = MutationRequestStatus.Approved, - PendingReason = null, - CreatedAt = createdAt, - UpdatedAt = updatedAt, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Approved, + PendingReason = null, + CreatedAt = createdAt, + UpdatedAt = updatedAt + }, Decisions = [ MutationRequestDecision.Create( @@ -495,8 +501,11 @@ private static MutationRequest CreateApprovalViewRequest( with { RequestId = requestId, - Status = MutationRequestStatus.Pending, - PendingReason = PendingMutationReason.Approval, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval + }, ApprovalRequirements = [ new MutationApprovalRequirement @@ -528,7 +537,12 @@ private static MutationRequest CreateDecisionViewRequest( { RequestId = requestId, Decisions = decisions, - UpdatedAt = decisions.Max(decision => decision.Timestamp) + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.ExternalCheck, + UpdatedAt = decisions.Max(decision => decision.Timestamp) + } }; private static MutationRequest CreateGovernedRequest( @@ -552,22 +566,31 @@ private static MutationRequest CreateGovernedRequest( => new MutationRequest { RequestId = requestId, - StateId = stateId, - StateType = stateType, - MutationType = mutationType, - Intent = new MutationIntent + Scope = new MutationRequestScopeDetails { - OperationName = mutationType, - Category = category, - Tags = tags, - Metadata = intentMetadata, - EstimatedBlastRadius = blastRadius + StateId = stateId, + StateType = stateType, + MutationType = mutationType + }, + Payload = new MutationRequestPayloadDetails + { + Intent = new MutationIntent + { + OperationName = mutationType, + Category = category, + Tags = tags, + Metadata = intentMetadata, + EstimatedBlastRadius = blastRadius + }, + Context = MutationContext.User(actorId, actorName, "Query test") + }, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = status, + PendingReason = pendingReason, + CreatedAt = createdAt, + UpdatedAt = updatedAt }, - Context = MutationContext.User(actorId, actorName, "Query test"), - Status = status, - PendingReason = pendingReason, - CreatedAt = createdAt, - UpdatedAt = updatedAt, Decisions = decisions, Metadata = requestMetadata, SideEffects = sideEffects ?? [] diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestFactoryTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestFactoryTests.cs index ce9bc8d..2ea12df 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestFactoryTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestFactoryTests.cs @@ -27,7 +27,7 @@ public void Approved_generic_overload_infers_state_and_mutation_type_names() Assert.Equal(nameof(TestState), request.StateType); Assert.Equal(nameof(TestMutation), request.MutationType); Assert.Equal(MutationRequestStatus.Approved, request.Status); - Assert.Equal("v1", request.ExpectedStateVersion); + Assert.Equal("v1", request.Versioning.ExpectedStateVersion); } [Fact] diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs index 68de9b9..2545cf9 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs @@ -87,7 +87,7 @@ public async Task ResolveAndStore_revalidation_marks_request_pending_for_revalid loaded.Decisions[^1].Type); Assert.Equal(MutationRequestStatus.Pending, loaded.Status); Assert.Equal(PendingMutationReason.Revalidation, loaded.PendingReason); - Assert.Equal("v15", loaded.ExpectedStateVersion); + Assert.Equal("v15", loaded.Versioning.ExpectedStateVersion); Assert.Equal(1, loaded.Revision); Assert.Equal(MutationRequestVersionResolutionOutcome.RevalidateOnLatestState, resolution.Outcome); Assert.Equal(loaded, resolution.Request); From 68d57d939508a92fe75bed0f88928f79b18a1d8e Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 20:40:48 +0200 Subject: [PATCH 14/17] docs(governance): update request lifecycle example --- .../Scenarios/GovernanceQueriesSampleData.cs | 52 +++++++++++++------ .../GovernanceRequestLifecycleScenario.cs | 2 +- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs index fa57e72..52f78c9 100644 --- a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs +++ b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs @@ -133,8 +133,13 @@ private static MutationRequest CreatePendingApprovalRequest( with { RequestId = requestId, - CreatedAt = createdAt, - UpdatedAt = createdAt, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval, + CreatedAt = createdAt, + UpdatedAt = createdAt + }, Metadata = new Dictionary { ["ticket"] = category == "Security" ? "INC-42" : "BILL-7" @@ -176,8 +181,13 @@ private static MutationRequest CreateExternalCheckRequest( with { RequestId = requestId, - CreatedAt = createdAt, - UpdatedAt = createdAt, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.ExternalCheck, + CreatedAt = createdAt, + UpdatedAt = createdAt + }, Metadata = new Dictionary { ["ticket"] = "REL-99" @@ -214,10 +224,13 @@ private static MutationRequest CreateRecentlyApprovedRequest( with { RequestId = requestId, - Status = MutationRequestStatus.Approved, - PendingReason = null, - CreatedAt = approvedAt.AddMinutes(-20), - UpdatedAt = approvedAt, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Approved, + PendingReason = null, + CreatedAt = approvedAt.AddMinutes(-20), + UpdatedAt = approvedAt + }, Metadata = new Dictionary { ["ticket"] = category == "Security" ? "INC-77" : "BILL-9" @@ -297,9 +310,12 @@ private static MutationRequest CreateResolvedRequest( with { RequestId = requestId, - Status = MutationRequestStatus.Approved, - CreatedAt = decisionTimestamp.AddMinutes(-30), - UpdatedAt = decisionTimestamp, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Approved, + CreatedAt = decisionTimestamp.AddMinutes(-30), + UpdatedAt = decisionTimestamp + }, Metadata = new Dictionary { ["ticket"] = "CFG-5" @@ -348,10 +364,16 @@ private static MutationRequest CreateExecutedRequest( with { RequestId = requestId, - Status = MutationRequestStatus.Executed, - CreatedAt = executedAt.AddMinutes(-15), - UpdatedAt = executedAt, - ExecutedAt = executedAt, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Executed, + CreatedAt = executedAt.AddMinutes(-15), + UpdatedAt = executedAt + }, + Versioning = new MutationRequestVersioningDetails + { + ExecutedAt = executedAt + }, SideEffects = [ SideEffect.Critical( diff --git a/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs b/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs index 99fc8a8..f2ed62b 100644 --- a/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs +++ b/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs @@ -132,7 +132,7 @@ private static void PrintRequestDetails(MutationRequest request) Console.WriteLine($" state: {request.Scope.StateId}"); Console.WriteLine($" status: {request.Lifecycle.Status}"); Console.WriteLine($" pending: {request.Lifecycle.PendingReason?.ToString() ?? "-"}"); - Console.WriteLine($" expires: {request.Lifecycle.ExpiresAt?.ToString(\"O\") ?? \"-\"}"); + Console.WriteLine($" expires: {request.Lifecycle.ExpiresAt?.ToString("O") ?? "-"}"); Console.WriteLine(" decisions:"); foreach (var decision in request.Decisions) From f1fc3d4aaa6951ed1bcb2541f2bf1b88d37758fc Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 20:41:23 +0200 Subject: [PATCH 15/17] docs(governance): update redis query example --- .../GovernanceRedisQueriesScenario.cs | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs index 299b0b4..e2e4938 100644 --- a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs +++ b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs @@ -69,9 +69,9 @@ public static async Task Run() } catch (RedisConnectionException exception) { - Console.Error.WriteLine($"Could not connect to Redis at '{redisConnectionString}'."); - Console.Error.WriteLine(exception.Message); - Console.Error.WriteLine("Start Redis locally or set MODULARITYKIT_REDIS to a reachable endpoint."); + await Console.Error.WriteLineAsync($"Could not connect to Redis at '{redisConnectionString}'."); + await Console.Error.WriteLineAsync(exception.Message); + await Console.Error.WriteLineAsync("Start Redis locally or set MODULARITYKIT_REDIS to a reachable endpoint."); } } @@ -137,8 +137,13 @@ private static MutationRequest CreatePendingApprovalRequest( with { RequestId = requestId, - CreatedAt = createdAt, - UpdatedAt = createdAt + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval, + CreatedAt = createdAt, + UpdatedAt = createdAt + } }; private static MutationRequest CreateExecutedRequest( @@ -160,10 +165,16 @@ private static MutationRequest CreateExecutedRequest( with { RequestId = requestId, - Status = MutationRequestStatus.Executed, - CreatedAt = executedAt.AddMinutes(-15), - UpdatedAt = executedAt, - ExecutedAt = executedAt, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Executed, + CreatedAt = executedAt.AddMinutes(-15), + UpdatedAt = executedAt + }, + Versioning = new MutationRequestVersioningDetails + { + ExecutedAt = executedAt + }, SideEffects = [ SideEffect.Critical( @@ -200,7 +211,7 @@ private static MutationRequest CreateExecutedRequest( ] }; - [SideEffectDataContract("examples.redis.execution-side-effect", 1)] + [SideEffectDataContract("examples.redis.execution-side-effect")] private sealed record RedisExecutionSideEffectData { public required string Ticket { get; init; } From ade41215587543fb4406f2329b7f2892039fe756 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 21:12:07 +0200 Subject: [PATCH 16/17] test(governance): reorganize test support --- ...ionRequestApprovalWorkflowApprovalTests.cs | 122 ++++ ...nRequestApprovalWorkflowExpirationTests.cs | 88 +++ .../MutationRequestApprovalWorkflowTests.cs | 399 ------------ ...rnanceExecutionManagerCompensationTests.cs | 114 ++++ ...overnanceExecutionManagerExecutionTests.cs | 202 ++++++ .../GovernanceExecutionManagerTests.cs | 443 ------------- .../MutationRequestLifecycleAtomicityTests.cs | 3 +- .../MutationRequestStoreContractTests.cs | 3 +- ...ularityKit.Mutator.Governance.Tests.csproj | 2 + .../MutationRequestQueryStoreRequestTests.cs | 186 ++++++ .../Queries/MutationRequestQueryStoreTests.cs | 604 ------------------ .../MutationRequestQueryStoreViewsTests.cs | 200 ++++++ ...equestVersionResolutionPersistenceTests.cs | 3 +- ...ationRequestApprovalWorkflowTestSupport.cs | 190 ++++++ .../GovernanceExecutionSideEffectData.cs | 20 + .../GovernanceExecutionManagerTestSupport.cs | 36 ++ .../TestSupport/Execution/Model/RoleState.cs | 14 + .../Mutations/PromoteRoleMutation.cs | 66 ++ .../Mutations/RollbackRoleMutation.cs | 67 ++ .../StaleSnapshotMutationRequestStore.cs | 16 +- ...utationRequestQueryStoreRequestBuilders.cs | 181 ++++++ .../MutationRequestQueryStoreViewBuilders.cs | 91 +++ .../Queries/Model/GovernanceSideEffectData.cs | 15 + .../MutationRequestTestFactory.cs | 11 +- 24 files changed, 1622 insertions(+), 1454 deletions(-) create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowApprovalTests.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowExpirationTests.cs delete mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerCompensationTests.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerExecutionTests.cs delete mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreRequestTests.cs delete mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreViewsTests.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Approval/Workflow/MutationRequestApprovalWorkflowTestSupport.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Effects/GovernanceExecutionSideEffectData.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Host/GovernanceExecutionManagerTestSupport.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Model/RoleState.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Mutations/PromoteRoleMutation.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Mutations/RollbackRoleMutation.cs rename Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/{ => Lifecycle/Storage}/StaleSnapshotMutationRequestStore.cs (83%) create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Queries/Builders/MutationRequestQueryStoreRequestBuilders.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Queries/Builders/MutationRequestQueryStoreViewBuilders.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Queries/Model/GovernanceSideEffectData.cs rename Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/{ => Requests}/MutationRequestTestFactory.cs (80%) diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowApprovalTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowApprovalTests.cs new file mode 100644 index 0000000..d4b9704 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowApprovalTests.cs @@ -0,0 +1,122 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Runtime.Approval.Execution; +using ModularityKit.Mutator.Governance.Runtime.Storage; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Approval.Workflow; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Approval; + +public sealed partial class MutationRequestApprovalWorkflowTests +{ + [Fact] + public void PendingApproval_maps_id_role_group_quorum_and_expiration_targets() + { + var expiresAt = DateTimeOffset.UtcNow.AddHours(1); + + var request = MutationRequestApprovalWorkflowTestSupport.CreateLinearApprovalRequest(); + + Assert.Equal(MutationRequestStatus.Pending, request.Status); + Assert.Equal(PendingMutationReason.Approval, request.PendingReason); + Assert.Equal(6, request.ApprovalRequirements.Count); + + var securityApprovals = request.ApprovalRequirements + .Where(requirement => requirement.ApprovalGroupId == "security-quorum") + .OrderBy(requirement => requirement.ApproverId) + .ToList(); + + Assert.Equal(3, securityApprovals.Count); + + void Action(MutationApprovalRequirement requirement) + { + Assert.Equal(2, requirement.RequiredApprovals); + Assert.Equal(expiresAt, requirement.ExpiresAt); + Assert.Equal(2, requirement.StepOrder); + } + + Assert.All(securityApprovals, Action); + + var financeApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverRole == "finance-approver"); + Assert.Equal(3, financeApproval.StepOrder); + + var operationsApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverGroup == "ops-oncall"); + Assert.Equal(4, operationsApproval.StepOrder); + } + + [Fact] + public async Task ApproveRequirement_supports_quorum_groups_and_marks_remaining_group_requirements_satisfied() + { + var store = new InMemoryMutationRequestStore(); + var manager = new MutationRequestApprovalWorkflowManager(store); + var request = await store.Create(MutationRequestApprovalWorkflowTestSupport.CreateQuorumApprovalRequest()); + + var aliceApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice"); + var afterAlice = await manager.ApproveRequirement( + request.RequestId, + aliceApproval.ApprovalId, + MutationContext.User("alice", "Alice", "Manager approved")); + + Assert.Equal(MutationRequestStatus.Pending, afterAlice.Status); + + var bobApproval = afterAlice.ApprovalRequirements.Single(requirement => requirement.ApproverId == "bob"); + var afterBob = await manager.ApproveRequirement( + request.RequestId, + bobApproval.ApprovalId, + MutationContext.User("bob", "Bob", "Security approved")); + + Assert.Equal(MutationRequestStatus.Pending, afterBob.Status); + + var carolApproval = afterBob.ApprovalRequirements.Single(requirement => requirement.ApproverId == "carol"); + var afterCarol = await manager.ApproveRequirement( + request.RequestId, + carolApproval.ApprovalId, + MutationContext.User("carol", "Carol", "Security approved")); + + Assert.Equal(MutationRequestStatus.Pending, afterCarol.Status); + Assert.Contains(afterCarol.Decisions, decision => + decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.QuorumSatisfied)); + + var securityGroup = afterCarol.ApprovalRequirements + .Where(requirement => requirement.ApprovalGroupId == "security-quorum") + .ToList(); + + Assert.Equal(2, securityGroup.Count(requirement => requirement.Status == MutationApprovalRequirementStatus.Approved)); + Assert.Equal(1, securityGroup.Count(requirement => requirement.Status == MutationApprovalRequirementStatus.Satisfied)); + + var financeApproval = afterCarol.ApprovalRequirements.Single(requirement => requirement.ApproverRole == "finance-approver"); + var afterFinance = await manager.ApproveRequirement( + request.RequestId, + financeApproval.ApprovalId, + MutationRequestApprovalWorkflowTestSupport.CreateRoleContext("frank", "Frank", "Finance approved", "finance-approver")); + + Assert.Equal(MutationRequestStatus.Approved, afterFinance.Status); + Assert.Null(afterFinance.PendingReason); + } + + [Fact] + public async Task ApproveRequirement_accepts_role_and_group_targeting() + { + var store = new InMemoryMutationRequestStore(); + var manager = new MutationRequestApprovalWorkflowManager(store); + var request = await store.Create(MutationRequestApprovalWorkflowTestSupport.CreateRoleAndGroupApprovalRequest()); + + var roleApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverRole == "security-admin"); + var afterRole = await manager.ApproveRequirement( + request.RequestId, + roleApproval.ApprovalId, + MutationRequestApprovalWorkflowTestSupport.CreateRoleContext("sara", "Sara", "Security role approved", "security-admin")); + + Assert.Equal(MutationApprovalRequirementStatus.Approved, afterRole.ApprovalRequirements.Single(requirement => requirement.ApprovalId == roleApproval.ApprovalId).Status); + Assert.Equal(MutationRequestStatus.Pending, afterRole.Status); + + var groupApproval = afterRole.ApprovalRequirements.Single(requirement => requirement.ApproverGroup == "ops-oncall"); + var afterGroup = await manager.ApproveRequirement( + request.RequestId, + groupApproval.ApprovalId, + MutationRequestApprovalWorkflowTestSupport.CreateGroupContext("oliver", "Oliver", "Operations approved", "ops-oncall")); + + Assert.Equal(MutationRequestStatus.Approved, afterGroup.Status); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowExpirationTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowExpirationTests.cs new file mode 100644 index 0000000..eb6a4ef --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowExpirationTests.cs @@ -0,0 +1,88 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Runtime.Approval.Execution; +using ModularityKit.Mutator.Governance.Runtime.Storage; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Approval.Workflow; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Approval; + +public sealed partial class MutationRequestApprovalWorkflowTests +{ + [Fact] + public async Task RejectRequirement_persists_structured_rejection_reason() + { + var store = new InMemoryMutationRequestStore(); + var manager = new MutationRequestApprovalWorkflowManager(store); + var request = await store.Create(MutationRequestApprovalWorkflowTestSupport.CreateLinearApprovalRequest()); + var aliceApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice"); + + var rejection = new MutationApprovalRejectionReason + { + Code = "missing-justification", + Category = "policy", + Message = "Change request did not include business justification.", + Metadata = new Dictionary + { + ["TicketId"] = "CHG-42" + } + }; + + var rejected = await manager.RejectRequirement( + request.RequestId, + aliceApproval.ApprovalId, + MutationContext.User("alice", "Alice", "Manager rejected"), + rejection: rejection); + + var rejectedRequirement = rejected.ApprovalRequirements.Single(requirement => requirement.ApprovalId == aliceApproval.ApprovalId); + + Assert.Equal(MutationRequestStatus.Rejected, rejected.Status); + Assert.Equal(MutationApprovalRequirementStatus.Rejected, rejectedRequirement.Status); + Assert.NotNull(rejectedRequirement.Rejection); + Assert.Equal("missing-justification", rejectedRequirement.Rejection!.Code); + Assert.Contains(rejected.Decisions, decision => + decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Rejected) && + Equals(decision.Metadata["RejectionCode"], "missing-justification")); + } + + [Fact] + public async Task ExpirePendingApprovals_rejects_requests_with_expired_approval_requirements() + { + var store = new InMemoryMutationRequestStore(); + var manager = new MutationRequestApprovalWorkflowManager(store); + var request = await store.Create(MutationRequestApprovalWorkflowTestSupport.CreateExpiredApprovalRequest()); + + var expired = await manager.ExpirePendingApprovals( + DateTimeOffset.UtcNow, + MutationContext.Service("approval-timeout-monitor", "Expire stale approvals")); + + var expiredRequest = Assert.Single(expired); + + Assert.Equal(request.RequestId, expiredRequest.RequestId); + Assert.Equal(MutationRequestStatus.Rejected, expiredRequest.Status); + Assert.Contains(expiredRequest.ApprovalRequirements, requirement => requirement.Status == MutationApprovalRequirementStatus.Expired); + Assert.Contains(expiredRequest.Decisions, decision => + decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Expired)); + } + + [Fact] + public async Task ApproveRequirement_throws_domain_exception_when_requirement_is_expired() + { + var store = new InMemoryMutationRequestStore(); + var manager = new MutationRequestApprovalWorkflowManager(store); + var request = await store.Create(MutationRequestApprovalWorkflowTestSupport.CreateExpiredApprovalRequest()); + var expiredApproval = request.ApprovalRequirements.Single(); + + var exception = await Assert.ThrowsAsync(() => + manager.ApproveRequirement( + request.RequestId, + expiredApproval.ApprovalId, + MutationContext.User("alice", "Alice", "Approve expired requirement"))); + + Assert.Equal(request.RequestId, exception.RequestId); + Assert.Equal(expiredApproval.ApprovalId, exception.ApprovalId); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs deleted file mode 100644 index fd29e17..0000000 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs +++ /dev/null @@ -1,399 +0,0 @@ -using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Abstractions.Intent; -using ModularityKit.Mutator.Abstractions.Policies; -using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; -using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -using ModularityKit.Mutator.Governance.Runtime.Approval.Execution; -using ModularityKit.Mutator.Governance.Runtime.Storage; -using Xunit; - -namespace ModularityKit.Mutator.Governance.Tests.Approval; - -public sealed class MutationRequestApprovalWorkflowTests -{ - [Fact] - public void PendingApproval_maps_id_role_group_quorum_and_expiration_targets() - { - var expiresAt = DateTimeOffset.UtcNow.AddHours(1); - - var request = MutationRequestFactory.PendingApproval( - stateId: "tenant-42:roles", - stateType: "IamRoleState", - mutationType: "GrantRoleMutation", - intent: CreateIntent(), - context: MutationContext.User("requester", "Requester", "Needs privileged access"), - requirements: - [ - PolicyRequirement.Approval("alice", "Manager approval"), - new PolicyRequirement - { - Type = "Approval", - Description = "Security quorum", - Data = new - { - Approvers = new[] { "bob", "carol", "dave" }, - StepOrder = 2, - ApprovalGroupId = "security-quorum", - Quorum = 2, - ExpiresAt = expiresAt, - Reason = "Security sign-off" - } - }, - new PolicyRequirement - { - Type = "Approval", - Description = "Finance role approval", - Data = new - { - ApproverRole = "finance-approver", - StepOrder = 3, - Reason = "Finance sign-off" - } - }, - new PolicyRequirement - { - Type = "Approval", - Description = "Operations group approval", - Data = new - { - ApproverGroup = "ops-oncall", - StepOrder = 4, - Reason = "Operational readiness" - } - } - ], - expectedStateVersion: "v10"); - - Assert.Equal(MutationRequestStatus.Pending, request.Status); - Assert.Equal(PendingMutationReason.Approval, request.PendingReason); - Assert.Equal(6, request.ApprovalRequirements.Count); - - var securityApprovals = request.ApprovalRequirements - .Where(requirement => requirement.ApprovalGroupId == "security-quorum") - .OrderBy(requirement => requirement.ApproverId) - .ToList(); - - Assert.Equal(3, securityApprovals.Count); - Assert.All(securityApprovals, requirement => - { - Assert.Equal(2, requirement.RequiredApprovals); - Assert.Equal(expiresAt, requirement.ExpiresAt); - Assert.Equal(2, requirement.StepOrder); - }); - - var financeApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverRole == "finance-approver"); - Assert.Equal(3, financeApproval.StepOrder); - - var operationsApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverGroup == "ops-oncall"); - Assert.Equal(4, operationsApproval.StepOrder); - } - - [Fact] - public async Task ApproveRequirement_supports_quorum_groups_and_marks_remaining_group_requirements_satisfied() - { - var store = new InMemoryMutationRequestStore(); - var manager = new MutationRequestApprovalWorkflowManager(store); - var request = await store.Create(CreateQuorumApprovalRequest()); - - var aliceApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice"); - var afterAlice = await manager.ApproveRequirement( - request.RequestId, - aliceApproval.ApprovalId, - MutationContext.User("alice", "Alice", "Manager approved")); - - Assert.Equal(MutationRequestStatus.Pending, afterAlice.Status); - - var bobApproval = afterAlice.ApprovalRequirements.Single(requirement => requirement.ApproverId == "bob"); - var afterBob = await manager.ApproveRequirement( - request.RequestId, - bobApproval.ApprovalId, - MutationContext.User("bob", "Bob", "Security approved")); - - Assert.Equal(MutationRequestStatus.Pending, afterBob.Status); - - var carolApproval = afterBob.ApprovalRequirements.Single(requirement => requirement.ApproverId == "carol"); - var afterCarol = await manager.ApproveRequirement( - request.RequestId, - carolApproval.ApprovalId, - MutationContext.User("carol", "Carol", "Security approved")); - - Assert.Equal(MutationRequestStatus.Pending, afterCarol.Status); - Assert.Contains(afterCarol.Decisions, decision => - decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.QuorumSatisfied)); - - var securityGroup = afterCarol.ApprovalRequirements - .Where(requirement => requirement.ApprovalGroupId == "security-quorum") - .ToList(); - - Assert.Equal(2, securityGroup.Count(requirement => requirement.Status == MutationApprovalRequirementStatus.Approved)); - Assert.Equal(1, securityGroup.Count(requirement => requirement.Status == MutationApprovalRequirementStatus.Satisfied)); - - var financeApproval = afterCarol.ApprovalRequirements.Single(requirement => requirement.ApproverRole == "finance-approver"); - var afterFinance = await manager.ApproveRequirement( - request.RequestId, - financeApproval.ApprovalId, - CreateRoleContext("frank", "Frank", "Finance approved", "finance-approver")); - - Assert.Equal(MutationRequestStatus.Approved, afterFinance.Status); - Assert.Null(afterFinance.PendingReason); - } - - [Fact] - public async Task ApproveRequirement_accepts_role_and_group_targeting() - { - var store = new InMemoryMutationRequestStore(); - var manager = new MutationRequestApprovalWorkflowManager(store); - var request = await store.Create(CreateRoleAndGroupApprovalRequest()); - - var roleApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverRole == "security-admin"); - var afterRole = await manager.ApproveRequirement( - request.RequestId, - roleApproval.ApprovalId, - CreateRoleContext("sara", "Sara", "Security role approved", "security-admin")); - - Assert.Equal(MutationApprovalRequirementStatus.Approved, afterRole.ApprovalRequirements.Single(requirement => requirement.ApprovalId == roleApproval.ApprovalId).Status); - Assert.Equal(MutationRequestStatus.Pending, afterRole.Status); - - var groupApproval = afterRole.ApprovalRequirements.Single(requirement => requirement.ApproverGroup == "ops-oncall"); - var afterGroup = await manager.ApproveRequirement( - request.RequestId, - groupApproval.ApprovalId, - CreateGroupContext("oliver", "Oliver", "Operations approved", "ops-oncall")); - - Assert.Equal(MutationRequestStatus.Approved, afterGroup.Status); - } - - [Fact] - public async Task RejectRequirement_persists_structured_rejection_reason() - { - var store = new InMemoryMutationRequestStore(); - var manager = new MutationRequestApprovalWorkflowManager(store); - var request = await store.Create(CreateLinearApprovalRequest()); - var aliceApproval = request.ApprovalRequirements.Single(requirement => requirement.ApproverId == "alice"); - - var rejection = new MutationApprovalRejectionReason - { - Code = "missing-justification", - Category = "policy", - Message = "Change request did not include business justification.", - Metadata = new Dictionary - { - ["TicketId"] = "CHG-42" - } - }; - - var rejected = await manager.RejectRequirement( - request.RequestId, - aliceApproval.ApprovalId, - MutationContext.User("alice", "Alice", "Manager rejected"), - rejection: rejection); - - var rejectedRequirement = rejected.ApprovalRequirements.Single(requirement => requirement.ApprovalId == aliceApproval.ApprovalId); - - Assert.Equal(MutationRequestStatus.Rejected, rejected.Status); - Assert.Equal(MutationApprovalRequirementStatus.Rejected, rejectedRequirement.Status); - Assert.NotNull(rejectedRequirement.Rejection); - Assert.Equal("missing-justification", rejectedRequirement.Rejection!.Code); - Assert.Contains(rejected.Decisions, decision => - decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Rejected) && - Equals(decision.Metadata["RejectionCode"], "missing-justification")); - } - - [Fact] - public async Task ExpirePendingApprovals_rejects_requests_with_expired_approval_requirements() - { - var store = new InMemoryMutationRequestStore(); - var manager = new MutationRequestApprovalWorkflowManager(store); - var request = await store.Create(CreateExpiredApprovalRequest()); - - var expired = await manager.ExpirePendingApprovals( - DateTimeOffset.UtcNow, - MutationContext.Service("approval-timeout-monitor", "Expire stale approvals")); - - var expiredRequest = Assert.Single(expired); - - Assert.Equal(request.RequestId, expiredRequest.RequestId); - Assert.Equal(MutationRequestStatus.Rejected, expiredRequest.Status); - Assert.Contains(expiredRequest.ApprovalRequirements, requirement => requirement.Status == MutationApprovalRequirementStatus.Expired); - Assert.Contains(expiredRequest.Decisions, decision => - decision.Type == MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Expired)); - } - - [Fact] - public async Task ApproveRequirement_throws_domain_exception_when_requirement_is_expired() - { - var store = new InMemoryMutationRequestStore(); - var manager = new MutationRequestApprovalWorkflowManager(store); - var request = await store.Create(CreateExpiredApprovalRequest()); - var expiredApproval = request.ApprovalRequirements.Single(); - - var exception = await Assert.ThrowsAsync(() => - manager.ApproveRequirement( - request.RequestId, - expiredApproval.ApprovalId, - MutationContext.User("alice", "Alice", "Approve expired requirement"))); - - Assert.Equal(request.RequestId, exception.RequestId); - Assert.Equal(expiredApproval.ApprovalId, exception.ApprovalId); - } - - private static MutationRequest CreateLinearApprovalRequest() - { - return MutationRequestFactory.PendingApproval( - stateId: "tenant-42:roles", - stateType: "IamRoleState", - mutationType: "GrantRoleMutation", - intent: CreateIntent(), - context: MutationContext.User("requester", "Requester", "Needs privileged access"), - requirements: - [ - PolicyRequirement.Approval("alice", "Manager approval") - ], - expectedStateVersion: "v10"); - } - - private static MutationRequest CreateQuorumApprovalRequest() - { - return MutationRequestFactory.PendingApproval( - stateId: "tenant-42:roles", - stateType: "IamRoleState", - mutationType: "GrantRoleMutation", - intent: CreateIntent(), - context: MutationContext.User("requester", "Requester", "Needs privileged access"), - requirements: - [ - PolicyRequirement.Approval("alice", "Manager approval"), - new PolicyRequirement - { - Type = "Approval", - Description = "Security quorum", - Data = new - { - Approvers = new[] { "bob", "carol", "dave" }, - StepOrder = 2, - ApprovalGroupId = "security-quorum", - Quorum = 2, - Reason = "Security sign-off" - } - }, - new PolicyRequirement - { - Type = "Approval", - Description = "Finance role approval", - Data = new - { - ApproverRole = "finance-approver", - StepOrder = 3, - Reason = "Finance sign-off" - } - } - ], - expectedStateVersion: "v10"); - } - - private static MutationRequest CreateRoleAndGroupApprovalRequest() - { - return MutationRequestFactory.PendingApproval( - stateId: "tenant-42:deploy", - stateType: "DeploymentState", - mutationType: "ApproveDeploymentMutation", - intent: CreateIntent(), - context: MutationContext.User("requester", "Requester", "Need deployment approval"), - requirements: - [ - new PolicyRequirement - { - Type = "Approval", - Description = "Security role approval", - Data = new - { - ApproverRole = "security-admin", - StepOrder = 1, - Reason = "Security review" - } - }, - new PolicyRequirement - { - Type = "Approval", - Description = "Operations group approval", - Data = new - { - ApproverGroup = "ops-oncall", - StepOrder = 2, - Reason = "Operational readiness" - } - } - ], - expectedStateVersion: "v7"); - } - - private static MutationRequest CreateExpiredApprovalRequest() - { - return MutationRequestFactory.PendingApproval( - stateId: "tenant-42:billing", - stateType: "BillingState", - mutationType: "IncreaseQuotaMutation", - intent: CreateIntent(), - context: MutationContext.User("requester", "Requester", "Need urgent quota increase"), - requirements: - [ - new PolicyRequirement - { - Type = "Approval", - Description = "Manager approval", - Data = new - { - Approver = "alice", - StepOrder = 1, - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5), - Reason = "Manager sign-off" - } - } - ], - expectedStateVersion: "v5"); - } - - private static MutationContext CreateRoleContext( - string actorId, - string actorName, - string reason, - params string[] roles) - { - return MutationContext.User(actorId, actorName, reason) with - { - Metadata = new Dictionary - { - ["ActorRoles"] = roles - } - }; - } - - private static MutationContext CreateGroupContext( - string actorId, - string actorName, - string reason, - params string[] groups) - { - return MutationContext.User(actorId, actorName, reason) with - { - Metadata = new Dictionary - { - ["ActorGroups"] = groups - } - }; - } - - private static MutationIntent CreateIntent() - { - return new MutationIntent - { - OperationName = "GrantRole", - Category = "Security", - Description = "Grant elevated role to tenant operator" - }; - } -} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerCompensationTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerCompensationTests.cs new file mode 100644 index 0000000..02dfdb7 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerCompensationTests.cs @@ -0,0 +1,114 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Links; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Host; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Model; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Mutations; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Execution; + +public sealed partial class GovernanceExecutionManagerTests +{ + [Fact] + public async Task ExecuteApproved_executes_operator_rollback_compensation_and_links_execution_history() + { + var (provider, _, auditor, historyStore, requestStore, _, executionManager) = + await GovernanceExecutionManagerTestSupport.CreateAsync(); + await using var _ = provider; + + var originalRequest = await requestStore.Create(MutationRequestFactory.Approved( + stateId: "tenant-42:roles", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated access", + IsReversible = true + }, + context: MutationContext.User("requester", "Requester", "Need access"), + expectedStateVersion: "v10")); + + var originalState = RoleState.Create("tenant-42:roles", role: "Reader", version: "v10"); + var originalMutation = new PromoteRoleMutation( + MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), + nextVersion: "v11"); + + var originalResult = await executionManager.ExecuteApproved( + originalRequest.RequestId, + originalMutation, + originalState, + governanceContext: MutationContext.Service("governance-runtime", "Execute approved request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); + + var compensationPlan = new GovernedCompensationPlan + { + OriginalRequestId = originalRequest.RequestId, + Kind = GovernedCompensationKind.Rollback, + Trigger = GovernedCompensationTrigger.OperatorRollback, + Reason = "Operator reverted the elevated role after incident review." + }; + + var compensationRequest = await requestStore.Create(CompensationMutationRequestFactory.Approved( + stateId: "tenant-42:roles", + intent: new MutationIntent + { + OperationName = "RollbackRole", + Category = "Security", + Description = "Restore the previous tenant role" + }, + context: MutationContext.User("operator-2", "Operator Two", "Rollback approved role mutation"), + compensation: compensationPlan, + expectedStateVersion: "v11")); + + var compensationMutation = new RollbackRoleMutation( + MutationContext.User("operator-2", "Operator Two", "Rollback approved role mutation"), + nextVersion: "v12"); + + var compensationResult = await executionManager.ExecuteApproved( + compensationRequest.RequestId, + compensationMutation, + originalResult.MutationResult!.NewState!, + governanceContext: MutationContext.Service("governance-runtime", "Execute operator rollback"), + strategy: VersionedRequestResolutionStrategy.RejectStale); + + Assert.True(compensationResult.WasExecuted); + Assert.Equal(GovernedExecutionKind.Compensation, compensationResult.ExecutionKind); + Assert.NotNull(compensationResult.Compensation); + Assert.Equal(originalRequest.RequestId, compensationResult.Compensation!.OriginalRequestId); + Assert.Equal(GovernedCompensationKind.Rollback, compensationResult.Compensation.Kind); + Assert.Contains( + compensationResult.Request.Execution.RelatedExecutions, + link => link.RequestId == originalRequest.RequestId && + link.Type == GovernedExecutionLinkType.Compensates); + + var compensatedOriginalRequest = await requestStore.Get(originalRequest.RequestId); + Assert.NotNull(compensatedOriginalRequest); + Assert.Contains( + compensatedOriginalRequest.Execution.RelatedExecutions, + link => link.RequestId == compensationRequest.RequestId && + link.Type == GovernedExecutionLinkType.CompensatedBy && + link.ExecutionKind == GovernedExecutionKind.Compensation); + Assert.Contains( + compensatedOriginalRequest.Decisions, + decision => decision.Type == MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Compensated)); + + var auditEntries = await auditor.GetAuditLogAsync(originalRequest.StateId); + var history = await historyStore.GetHistoryAsync(originalRequest.StateId); + + Assert.Equal(2, auditEntries.Count); + Assert.Equal(2, history.Entries.Count); + Assert.Equal("Compensation", auditEntries[1].Context.Metadata["GovernanceExecutionKind"]); + Assert.Equal("Compensation", history.Entries[1].Context.Metadata["GovernanceExecutionKind"]); + + var auditCompensation = Assert.IsAssignableFrom>( + auditEntries[1].Context.Metadata["GovernanceCompensation"]); + Assert.Equal(originalRequest.RequestId, auditCompensation["OriginalRequestId"]); + Assert.Equal("Rollback", auditCompensation["Kind"]); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerExecutionTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerExecutionTests.cs new file mode 100644 index 0000000..fa09377 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerExecutionTests.cs @@ -0,0 +1,202 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Host; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Model; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Mutations; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Execution; + +public sealed partial class GovernanceExecutionManagerTests +{ + [Fact] + public async Task ExecuteApproved_executes_request_persists_resulting_version_and_correlates_audit_history() + { + var (provider, _, auditor, historyStore, requestStore, _, executionManager) = + await GovernanceExecutionManagerTestSupport.CreateAsync(); + await using var _ = provider; + + var request = await requestStore.Create(MutationRequestFactory.Approved( + stateId: "tenant-42:roles", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + 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", + metadata: new Dictionary + { + ["ticket"] = "INC-42" + })); + var mutation = new PromoteRoleMutation( + MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), + nextVersion: "v11"); + var state = RoleState.Create("tenant-42:roles", role: "Reader", version: "v10"); + + var result = await executionManager.ExecuteApproved( + request.RequestId, + mutation, + state, + governanceContext: MutationContext.Service("governance-runtime", "Execute approved request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); + + Assert.True(result.WasExecuted); + Assert.NotNull(result.MutationResult); + Assert.Equal("v11", result.ResultingStateVersion); + Assert.Equal(MutationRequestStatus.Executed, result.Request.Status); + Assert.Equal("v11", result.Request.Versioning.ResultingStateVersion); + Assert.Equal("v11", result.Request.Versioning.ExpectedStateVersion); + Assert.NotNull(result.Request.Versioning.ExecutedAt); + Assert.Single(result.Request.SideEffects); + Assert.Equal("RoleElevated", result.Request.SideEffects[0].Type); + Assert.Equal("governance.execution-effect", result.Request.SideEffects[0].DataContractType); + Assert.Equal( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), + result.Request.Decisions[^1].Type); + + var auditEntries = await auditor.GetAuditLogAsync(request.StateId); + var history = await historyStore.GetHistoryAsync(request.StateId); + + Assert.Single(auditEntries); + 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] + public async Task ExecuteApproved_does_not_execute_when_stale_resolution_rejects_request() + { + var (provider, _, _, _, requestStore, _, executionManager) = + await GovernanceExecutionManagerTestSupport.CreateAsync(); + await using var _ = provider; + + var request = await requestStore.Create(MutationRequestFactory.Approved( + stateId: "tenant-42:roles", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated access" + }, + context: MutationContext.User("requester", "Requester", "Need access"), + expectedStateVersion: "v10")); + var mutation = new PromoteRoleMutation( + MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), + nextVersion: "v11"); + var state = RoleState.Create("tenant-42:roles", role: "Reader", version: "v15"); + + var result = await executionManager.ExecuteApproved( + request.RequestId, + mutation, + state, + governanceContext: MutationContext.Service("governance-runtime", "Reject stale request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); + + Assert.False(result.WasExecuted); + Assert.Null(result.MutationResult); + Assert.Equal(MutationRequestStatus.Rejected, result.Request.Status); + Assert.Equal(MutationRequestVersionResolutionOutcome.RejectedAsStale, result.Resolution.Outcome); + Assert.Equal( + MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RejectedAsStale), + result.Request.Decisions[^1].Type); + } + + [Fact] + public async Task ExecuteApproved_requires_renewed_approval_before_execution_when_strategy_demands_it() + { + var (provider, _, _, _, requestStore, _, executionManager) = + await GovernanceExecutionManagerTestSupport.CreateAsync(); + await using var _ = provider; + + var request = await requestStore.Create(MutationRequestFactory.Approved( + stateId: "tenant-42:roles", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated access" + }, + context: MutationContext.User("requester", "Requester", "Need access"), + expectedStateVersion: "v10")); + var mutation = new PromoteRoleMutation( + MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), + nextVersion: "v11"); + var state = RoleState.Create("tenant-42:roles", role: "Reader", version: "v15"); + + var result = await executionManager.ExecuteApproved( + request.RequestId, + mutation, + state, + governanceContext: MutationContext.Service("governance-runtime", "Require renewed approval"), + strategy: VersionedRequestResolutionStrategy.RequireRenewedApproval); + + Assert.False(result.WasExecuted); + Assert.Null(result.MutationResult); + Assert.Equal(MutationRequestStatus.Pending, result.Request.Status); + Assert.Equal(PendingMutationReason.Approval, result.Request.PendingReason); + Assert.Equal("v15", result.Request.Versioning.ExpectedStateVersion); + Assert.Equal(MutationRequestVersionResolutionOutcome.RequiresRenewedApproval, result.Resolution.Outcome); + } + + [Fact] + public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_when_strategy_demands_it() + { + var (provider, _, _, _, requestStore, _, executionManager) = + await GovernanceExecutionManagerTestSupport.CreateAsync(); + await using var _ = provider; + + var request = await requestStore.Create(MutationRequestFactory.Approved( + stateId: "tenant-42:roles", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated access" + }, + context: MutationContext.User("requester", "Requester", "Need access"), + expectedStateVersion: "v10")); + var mutation = new PromoteRoleMutation( + MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), + nextVersion: "v16"); + var state = RoleState.Create("tenant-42:roles", role: "Reader", version: "v15"); + + var result = await executionManager.ExecuteApproved( + request.RequestId, + mutation, + state, + governanceContext: MutationContext.Service("governance-runtime", "Revalidate and execute"), + strategy: VersionedRequestResolutionStrategy.RevalidateOnLatestState); + + Assert.True(result.WasExecuted); + Assert.NotNull(result.MutationResult); + Assert.Equal(MutationRequestVersionResolutionOutcome.RevalidateOnLatestState, result.Resolution.Outcome); + Assert.Equal(MutationRequestStatus.Executed, result.Request.Status); + Assert.Equal("v16", result.ResultingStateVersion); + Assert.Equal("v16", result.Request.Versioning.ResultingStateVersion); + Assert.Equal("v16", result.Request.Versioning.ExpectedStateVersion); + Assert.Equal( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), + result.Request.Decisions[^1].Type); + Assert.Contains( + result.Request.Decisions, + decision => decision.Type == MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RevalidationRequired)); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs deleted file mode 100644 index b6c97f3..0000000 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs +++ /dev/null @@ -1,443 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using ModularityKit.Mutator.Abstractions; -using ModularityKit.Mutator.Abstractions.Audit; -using ModularityKit.Mutator.Abstractions.Changes; -using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Abstractions.Effects; -using ModularityKit.Mutator.Abstractions.Engine; -using ModularityKit.Mutator.Abstractions.History; -using ModularityKit.Mutator.Abstractions.Intent; -using ModularityKit.Mutator.Abstractions.Results; -using ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; -using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Compensation; -using ModularityKit.Mutator.Governance.Abstractions.Execution.Model.Links; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; -using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; -using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; -using ModularityKit.Mutator.Governance.Runtime.Execution.Orchestration; -using ModularityKit.Mutator.Governance.Runtime.Resolution.Execution; -using ModularityKit.Mutator.Governance.Runtime.Storage; -using ModularityKit.Mutator.Runtime; -using Xunit; - -namespace ModularityKit.Mutator.Governance.Tests.Execution; - -public sealed class GovernanceExecutionManagerTests -{ - [Fact] - public async Task ExecuteApproved_executes_request_persists_resulting_version_and_correlates_audit_history() - { - var services = new ServiceCollection(); - services.AddMutators(MutationEngineOptions.Strict); - await using var provider = services.BuildServiceProvider(); - - var engine = provider.GetRequiredService(); - var auditor = provider.GetRequiredService(); - var historyStore = provider.GetRequiredService(); - var requestStore = new InMemoryMutationRequestStore(); - var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); - var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); - - var request = await requestStore.Create(MutationRequestFactory.Approved( - stateId: "tenant-42:roles", - intent: new MutationIntent - { - OperationName = "GrantRole", - Category = "Security", - 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", - metadata: new Dictionary - { - ["ticket"] = "INC-42" - })); - var mutation = new PromoteRoleMutation( - MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), - nextVersion: "v11"); - var state = RoleState.Create("tenant-42:roles", role: "Reader", version: "v10"); - - var result = await executionManager.ExecuteApproved( - request.RequestId, - mutation, - state, - governanceContext: MutationContext.Service("governance-runtime", "Execute approved request"), - strategy: VersionedRequestResolutionStrategy.RejectStale); - - Assert.True(result.WasExecuted); - Assert.NotNull(result.MutationResult); - Assert.Equal("v11", result.ResultingStateVersion); - Assert.Equal(MutationRequestStatus.Executed, result.Request.Status); - Assert.Equal("v11", result.Request.Versioning.ResultingStateVersion); - Assert.Equal("v11", result.Request.Versioning.ExpectedStateVersion); - Assert.NotNull(result.Request.Versioning.ExecutedAt); - Assert.Single(result.Request.SideEffects); - Assert.Equal("RoleElevated", result.Request.SideEffects[0].Type); - Assert.Equal("governance.execution-effect", result.Request.SideEffects[0].DataContractType); - Assert.Equal( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), - result.Request.Decisions[^1].Type); - - var auditEntries = await auditor.GetAuditLogAsync(request.StateId); - var history = await historyStore.GetHistoryAsync(request.StateId); - - Assert.Single(auditEntries); - 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] - public async Task ExecuteApproved_does_not_execute_when_stale_resolution_rejects_request() - { - var services = new ServiceCollection(); - services.AddMutators(MutationEngineOptions.Strict); - await using var provider = services.BuildServiceProvider(); - - var engine = provider.GetRequiredService(); - var requestStore = new InMemoryMutationRequestStore(); - var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); - var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); - - var request = await requestStore.Create(MutationRequestFactory.Approved( - stateId: "tenant-42:roles", - intent: new MutationIntent - { - OperationName = "GrantRole", - Category = "Security", - Description = "Grant elevated access" - }, - context: MutationContext.User("requester", "Requester", "Need access"), - expectedStateVersion: "v10")); - var mutation = new PromoteRoleMutation( - MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), - nextVersion: "v11"); - var state = RoleState.Create("tenant-42:roles", role: "Reader", version: "v15"); - - var result = await executionManager.ExecuteApproved( - request.RequestId, - mutation, - state, - governanceContext: MutationContext.Service("governance-runtime", "Reject stale request"), - strategy: VersionedRequestResolutionStrategy.RejectStale); - - Assert.False(result.WasExecuted); - Assert.Null(result.MutationResult); - Assert.Equal(MutationRequestStatus.Rejected, result.Request.Status); - Assert.Equal(MutationRequestVersionResolutionOutcome.RejectedAsStale, result.Resolution.Outcome); - Assert.Equal( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RejectedAsStale), - result.Request.Decisions[^1].Type); - } - - [Fact] - public async Task ExecuteApproved_requires_renewed_approval_before_execution_when_strategy_demands_it() - { - var services = new ServiceCollection(); - services.AddMutators(MutationEngineOptions.Strict); - await using var provider = services.BuildServiceProvider(); - - var engine = provider.GetRequiredService(); - var requestStore = new InMemoryMutationRequestStore(); - var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); - var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); - - var request = await requestStore.Create(MutationRequestFactory.Approved( - stateId: "tenant-42:roles", - intent: new MutationIntent - { - OperationName = "GrantRole", - Category = "Security", - Description = "Grant elevated access" - }, - context: MutationContext.User("requester", "Requester", "Need access"), - expectedStateVersion: "v10")); - var mutation = new PromoteRoleMutation( - MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), - nextVersion: "v11"); - var state = RoleState.Create("tenant-42:roles", role: "Reader", version: "v15"); - - var result = await executionManager.ExecuteApproved( - request.RequestId, - mutation, - state, - governanceContext: MutationContext.Service("governance-runtime", "Require renewed approval"), - strategy: VersionedRequestResolutionStrategy.RequireRenewedApproval); - - Assert.False(result.WasExecuted); - Assert.Null(result.MutationResult); - Assert.Equal(MutationRequestStatus.Pending, result.Request.Status); - Assert.Equal(PendingMutationReason.Approval, result.Request.PendingReason); - Assert.Equal("v15", result.Request.Versioning.ExpectedStateVersion); - Assert.Equal(MutationRequestVersionResolutionOutcome.RequiresRenewedApproval, result.Resolution.Outcome); - } - - [Fact] - public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_when_strategy_demands_it() - { - var services = new ServiceCollection(); - services.AddMutators(MutationEngineOptions.Strict); - await using var provider = services.BuildServiceProvider(); - - var engine = provider.GetRequiredService(); - var requestStore = new InMemoryMutationRequestStore(); - var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); - var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); - - var request = await requestStore.Create(MutationRequestFactory.Approved( - stateId: "tenant-42:roles", - intent: new MutationIntent - { - OperationName = "GrantRole", - Category = "Security", - Description = "Grant elevated access" - }, - context: MutationContext.User("requester", "Requester", "Need access"), - expectedStateVersion: "v10")); - var mutation = new PromoteRoleMutation( - MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), - nextVersion: "v16"); - var state = RoleState.Create("tenant-42:roles", role: "Reader", version: "v15"); - - var result = await executionManager.ExecuteApproved( - request.RequestId, - mutation, - state, - governanceContext: MutationContext.Service("governance-runtime", "Revalidate and execute"), - strategy: VersionedRequestResolutionStrategy.RevalidateOnLatestState); - - Assert.True(result.WasExecuted); - Assert.NotNull(result.MutationResult); - Assert.Equal(MutationRequestVersionResolutionOutcome.RevalidateOnLatestState, result.Resolution.Outcome); - Assert.Equal(MutationRequestStatus.Executed, result.Request.Status); - Assert.Equal("v16", result.ResultingStateVersion); - Assert.Equal("v16", result.Request.Versioning.ResultingStateVersion); - Assert.Equal("v16", result.Request.Versioning.ExpectedStateVersion); - Assert.Equal( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), - result.Request.Decisions[^1].Type); - Assert.Contains( - result.Request.Decisions, - decision => decision.Type == MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RevalidationRequired)); - } - - [Fact] - public async Task ExecuteApproved_executes_operator_rollback_compensation_and_links_execution_history() - { - var services = new ServiceCollection(); - services.AddMutators(MutationEngineOptions.Strict); - await using var provider = services.BuildServiceProvider(); - - var engine = provider.GetRequiredService(); - var auditor = provider.GetRequiredService(); - var historyStore = provider.GetRequiredService(); - var requestStore = new InMemoryMutationRequestStore(); - var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); - var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); - - var originalRequest = await requestStore.Create(MutationRequestFactory.Approved( - stateId: "tenant-42:roles", - intent: new MutationIntent - { - OperationName = "GrantRole", - Category = "Security", - Description = "Grant elevated access", - IsReversible = true - }, - context: MutationContext.User("requester", "Requester", "Need access"), - expectedStateVersion: "v10")); - - var originalState = RoleState.Create("tenant-42:roles", role: "Reader", version: "v10"); - var originalMutation = new PromoteRoleMutation( - MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), - nextVersion: "v11"); - - var originalResult = await executionManager.ExecuteApproved( - originalRequest.RequestId, - originalMutation, - originalState, - governanceContext: MutationContext.Service("governance-runtime", "Execute approved request"), - strategy: VersionedRequestResolutionStrategy.RejectStale); - - var compensationPlan = new GovernedCompensationPlan - { - OriginalRequestId = originalRequest.RequestId, - Kind = GovernedCompensationKind.Rollback, - Trigger = GovernedCompensationTrigger.OperatorRollback, - Reason = "Operator reverted the elevated role after incident review." - }; - - var compensationRequest = await requestStore.Create(CompensationMutationRequestFactory.Approved( - stateId: "tenant-42:roles", - intent: new MutationIntent - { - OperationName = "RollbackRole", - Category = "Security", - Description = "Restore the previous tenant role" - }, - context: MutationContext.User("operator-2", "Operator Two", "Rollback approved role mutation"), - compensation: compensationPlan, - expectedStateVersion: "v11")); - - var compensationMutation = new RollbackRoleMutation( - MutationContext.User("operator-2", "Operator Two", "Rollback approved role mutation"), - nextVersion: "v12"); - - var compensationResult = await executionManager.ExecuteApproved( - compensationRequest.RequestId, - compensationMutation, - originalResult.MutationResult!.NewState!, - governanceContext: MutationContext.Service("governance-runtime", "Execute operator rollback"), - strategy: VersionedRequestResolutionStrategy.RejectStale); - - Assert.True(compensationResult.WasExecuted); - Assert.Equal(GovernedExecutionKind.Compensation, compensationResult.ExecutionKind); - Assert.NotNull(compensationResult.Compensation); - Assert.Equal(originalRequest.RequestId, compensationResult.Compensation!.OriginalRequestId); - Assert.Equal(GovernedCompensationKind.Rollback, compensationResult.Compensation.Kind); - Assert.Contains( - compensationResult.Request.Execution.RelatedExecutions, - link => link.RequestId == originalRequest.RequestId && - link.Type == GovernedExecutionLinkType.Compensates); - - var compensatedOriginalRequest = await requestStore.Get(originalRequest.RequestId); - Assert.NotNull(compensatedOriginalRequest); - Assert.Contains( - compensatedOriginalRequest!.Execution.RelatedExecutions, - link => link.RequestId == compensationRequest.RequestId && - link.Type == GovernedExecutionLinkType.CompensatedBy && - link.ExecutionKind == GovernedExecutionKind.Compensation); - Assert.Contains( - compensatedOriginalRequest.Decisions, - decision => decision.Type == MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Compensated)); - - var auditEntries = await auditor.GetAuditLogAsync(originalRequest.StateId); - var history = await historyStore.GetHistoryAsync(originalRequest.StateId); - - Assert.Equal(2, auditEntries.Count); - Assert.Equal(2, history.Entries.Count); - Assert.Equal("Compensation", auditEntries[1].Context.Metadata["GovernanceExecutionKind"]); - Assert.Equal("Compensation", history.Entries[1].Context.Metadata["GovernanceExecutionKind"]); - - var auditCompensation = Assert.IsAssignableFrom>( - auditEntries[1].Context.Metadata["GovernanceCompensation"]); - Assert.Equal(originalRequest.RequestId, auditCompensation["OriginalRequestId"]); - Assert.Equal("Rollback", auditCompensation["Kind"]); - } - - private sealed record RoleState(string StateId, string Role, string Version) : IVersionedState - { - public static RoleState Create(string stateId, string role, string version) => new(stateId, role, version); - } - - private sealed class PromoteRoleMutation(MutationContext context, string nextVersion) : IMutation - { - public MutationIntent Intent { get; } = new() - { - OperationName = "PromoteRole", - Category = "Security", - Description = "Promote tenant role after governance approval" - }; - - public MutationContext Context { get; } = context; - - public MutationResult Apply(RoleState state) - { - var newState = state with - { - Role = "Admin", - Version = nextVersion - }; - - return MutationResult.Success( - newState, - ChangeSet.Single(StateChange.Modified("Role", state.Role, newState.Role)), - [ - SideEffect.Create( - type: "RoleElevated", - description: "Governed execution elevated the role", - data: new GovernanceExecutionSideEffectData - { - RequestStateId = state.StateId, - NewRole = newState.Role - }) - ]); - } - - public ValidationResult Validate(RoleState state) - { - return state.Role == "Admin" - ? ValidationResult.WithError("Role", "Role is already Admin.") - : ValidationResult.Success(); - } - - public MutationResult Simulate(RoleState state) => Apply(state); - } - - private sealed class RollbackRoleMutation(MutationContext context, string nextVersion) : IMutation - { - public MutationIntent Intent { get; } = new() - { - OperationName = "RollbackRole", - Category = "Security", - Description = "Rollback tenant role to Reader", - IsReversible = false - }; - - public MutationContext Context { get; } = context; - - public MutationResult Apply(RoleState state) - { - var newState = state with - { - Role = "Reader", - Version = nextVersion - }; - - return MutationResult.Success( - newState, - ChangeSet.Single(StateChange.Modified("Role", state.Role, newState.Role)), - [ - SideEffect.Create( - type: "RoleRollback", - description: "Governed compensation restored the previous role", - data: new GovernanceExecutionSideEffectData - { - RequestStateId = state.StateId, - NewRole = newState.Role - }) - ]); - } - - public ValidationResult Validate(RoleState state) - { - return state.Role == "Reader" - ? ValidationResult.WithError("Role", "Role is already Reader.") - : ValidationResult.Success(); - } - - public MutationResult Simulate(RoleState state) => Apply(state); - } - - [SideEffectDataContract("governance.execution-effect", 1)] - private sealed record GovernanceExecutionSideEffectData - { - public required string RequestStateId { get; init; } - - public required string NewRole { get; init; } - } -} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs index 8264d18..9b90884 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestLifecycleAtomicityTests.cs @@ -2,7 +2,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Runtime.Lifecycle.Execution; -using ModularityKit.Mutator.Governance.Tests.TestSupport; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Lifecycle.Storage; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Requests; using Xunit; namespace ModularityKit.Mutator.Governance.Tests.Lifecycle; diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs index 8d99ce6..7e01667 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Lifecycle/MutationRequestStoreContractTests.cs @@ -2,9 +2,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Runtime.Storage; -using ModularityKit.Mutator.Governance.Tests.TestSupport; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Requests; using Xunit; namespace ModularityKit.Mutator.Governance.Tests.Lifecycle; diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj b/Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj index a34a2b9..ef50225 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj +++ b/Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj @@ -5,6 +5,8 @@ enable enable false + true + $(NoWarn);1591 diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreRequestTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreRequestTests.cs new file mode 100644 index 0000000..1e6e69b --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreRequestTests.cs @@ -0,0 +1,186 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +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.Runtime.Storage; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Queries.Builders; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Queries; + +public sealed partial class MutationRequestQueryStoreTests +{ + [Fact] + public async Task QueryAsync_filters_requests_by_governance_dimensions() + { + var store = new InMemoryMutationRequestStore(); + var approvalRequest = await store.Create(MutationRequestQueryStoreRequestBuilders.CreateGovernedRequest( + requestId: "req-approval", + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + actorId: "alice", + actorName: "Alice", + category: "Security", + tags: new HashSet { "security", "urgent" }, + 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), + status: MutationRequestStatus.Pending, + pendingReason: PendingMutationReason.Approval, + decisions: + [ + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationContext.User("alice", "Alice", "Need review")), + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), + MutationContext.User("alice", "Alice", "Pending approval")), + MutationRequestDecision.Create( + MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), + MutationContext.User("alice", "Alice", "Approval requested")) + ])); + + await store.Create(MutationRequestQueryStoreRequestBuilders.CreateGovernedRequest( + requestId: "req-other", + stateId: "tenant-42:billing", + stateType: "QuotaState", + mutationType: "IncreaseQuotaMutation", + actorId: "bob", + actorName: "Bob", + category: "Billing", + tags: new HashSet { "billing" }, + 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), + status: MutationRequestStatus.Approved, + pendingReason: null, + decisions: + [ + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationContext.User("bob", "Bob", "Request submitted")), + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + MutationContext.User("bob", "Bob", "Approved")) + ])); + + var results = await store.QueryAsync(new MutationRequestQuery + { + 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); + Assert.Equal(approvalRequest.RequestId, results[0].RequestId); + } + + [Fact] + public async Task GetPendingRequestsAsync_returns_only_pending_requests() + { + var store = new InMemoryMutationRequestStore(); + var pending = await store.Create(MutationRequestQueryStoreRequestBuilders.CreateSimpleRequest( + "req-pending", + MutationRequestStatus.Pending, + PendingMutationReason.ExternalCheck, + new DateTimeOffset(2026, 6, 1, 8, 0, 0, TimeSpan.Zero))); + + await store.Create(MutationRequestQueryStoreRequestBuilders.CreateSimpleRequest( + "req-approved", + MutationRequestStatus.Approved, + null, + new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero))); + + var results = await store.GetPendingRequestsAsync(); + + Assert.Single(results); + 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(MutationRequestQueryStoreRequestBuilders.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(MutationRequestQueryStoreRequestBuilders.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); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs deleted file mode 100644 index 5f76434..0000000 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs +++ /dev/null @@ -1,604 +0,0 @@ -using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Abstractions.Effects; -using ModularityKit.Mutator.Abstractions.Intent; -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.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; -using ModularityKit.Mutator.Governance.Runtime.Storage; -using Xunit; - -namespace ModularityKit.Mutator.Governance.Tests.Queries; - -public sealed class MutationRequestQueryStoreTests -{ - [Fact] - public async Task QueryAsync_filters_requests_by_governance_dimensions() - { - var store = new InMemoryMutationRequestStore(); - var approvalRequest = await store.Create(CreateGovernedRequest( - requestId: "req-approval", - stateId: "tenant-42:roles", - stateType: "IamRoleState", - mutationType: "GrantRoleMutation", - actorId: "alice", - actorName: "Alice", - category: "Security", - tags: new HashSet { "security", "urgent" }, - 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), - status: MutationRequestStatus.Pending, - pendingReason: PendingMutationReason.Approval, - decisions: - [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), - MutationContext.User("alice", "Alice", "Need review")), - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), - MutationContext.User("alice", "Alice", "Pending approval")), - MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), - MutationContext.User("alice", "Alice", "Approval requested")) - ])); - - await store.Create(CreateGovernedRequest( - requestId: "req-other", - stateId: "tenant-42:billing", - stateType: "QuotaState", - mutationType: "IncreaseQuotaMutation", - actorId: "bob", - actorName: "Bob", - category: "Billing", - tags: new HashSet { "billing" }, - 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), - status: MutationRequestStatus.Approved, - pendingReason: null, - decisions: - [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), - MutationContext.User("bob", "Bob", "Request submitted")), - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), - MutationContext.User("bob", "Bob", "Approved")) - ])); - - var results = await store.QueryAsync(new MutationRequestQuery - { - 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); - Assert.Equal(approvalRequest.RequestId, results[0].RequestId); - } - - [Fact] - public async Task GetPendingRequestsAsync_returns_only_pending_requests() - { - var store = new InMemoryMutationRequestStore(); - var pending = await store.Create(CreateSimpleRequest( - "req-pending", - MutationRequestStatus.Pending, - PendingMutationReason.ExternalCheck, - new DateTimeOffset(2026, 6, 1, 8, 0, 0, TimeSpan.Zero))); - - await store.Create(CreateSimpleRequest( - "req-approved", - MutationRequestStatus.Approved, - null, - new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero))); - - var results = await store.GetPendingRequestsAsync(); - - Assert.Single(results); - 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 QueryAsync_can_filter_by_persisted_side_effect_dimensions() - { - var store = new InMemoryMutationRequestStore(); - - await store.Create(CreateGovernedRequest( - requestId: "req-side-effect", - 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.Executed, - pendingReason: null, - decisions: [], - sideEffects: - [ - SideEffect.Critical( - type: "WorkflowRejected", - description: "Manual review required", - data: new GovernanceSideEffectData - { - Reference = "INC-42" - }) - ])); - - await store.Create(CreateGovernedRequest( - requestId: "req-other-effect", - 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.Executed, - pendingReason: null, - decisions: [], - sideEffects: - [ - SideEffect.Create( - type: "QuotaRaised", - description: "Quota updated") - ])); - - var results = await store.QueryAsync(new MutationRequestQuery - { - SideEffects = new MutationRequestSideEffectFilter - { - Types = new HashSet { "WorkflowRejected" }, - DataContractTypes = new HashSet { "governance.side-effect" }, - Severities = new HashSet { SideEffectSeverity.Critical }, - RequiresAction = true - } - }); - - Assert.Single(results); - Assert.Equal("req-side-effect", results[0].RequestId); - } - - [Fact] - public async Task GetPendingApprovalQueueAsync_and_GetRecentApprovalsAsync_return_approval_oriented_views() - { - var store = new InMemoryMutationRequestStore(); - - var pendingApproval = await store.Create(CreateSimpleRequest( - "req-pending-approval", - MutationRequestStatus.Pending, - PendingMutationReason.Approval, - new DateTimeOffset(2026, 6, 1, 8, 0, 0, TimeSpan.Zero))); - - var recentApproval = await store.Create(CreateApprovedRequest( - "req-recent-approval", - new DateTimeOffset(2026, 6, 2, 8, 0, 0, TimeSpan.Zero), - new DateTimeOffset(2026, 6, 2, 9, 15, 0, TimeSpan.Zero))); - - await store.Create(CreateApprovedRequest( - "req-older-approval", - new DateTimeOffset(2026, 6, 1, 8, 0, 0, TimeSpan.Zero), - new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero))); - - var pendingQueue = await store.GetPendingApprovalQueueAsync(); - var recentApprovals = await store.GetRecentApprovalsAsync(take: 1); - - Assert.Single(pendingQueue); - Assert.Equal(pendingApproval.RequestId, pendingQueue[0].RequestId); - - Assert.Single(recentApprovals); - Assert.Equal(recentApproval.RequestId, recentApprovals[0].RequestId); - } - - [Fact] - public async Task GetPendingApprovalsAsync_filters_approval_views_by_approver_dimensions() - { - var store = new InMemoryMutationRequestStore(); - - await store.Create(CreateApprovalViewRequest( - requestId: "req-security", - approverId: "security-lead", - approverRole: "SecurityLead", - approverGroup: "security", - category: "Security", - approvalStatus: MutationApprovalRequirementStatus.Pending)); - - await store.Create(CreateApprovalViewRequest( - requestId: "req-platform", - approverId: "platform-owner", - approverRole: "PlatformOwner", - approverGroup: "platform", - category: "Platform", - approvalStatus: MutationApprovalRequirementStatus.Pending)); - - var approvals = await store.GetPendingApprovalsAsync(new MutationApprovalQuery - { - ApproverIds = new HashSet { "security-lead" }, - ApproverRoles = new HashSet { "SecurityLead" }, - ApproverGroups = new HashSet { "security" }, - Categories = new HashSet { "Security" } - }); - - Assert.Single(approvals); - Assert.Equal("req-security", approvals[0].Request.RequestId); - Assert.Equal("security-lead", approvals[0].Approval.ApproverId); - } - - [Fact] - public async Task GetRecentDecisionsAsync_returns_filtered_decision_views() - { - var store = new InMemoryMutationRequestStore(); - - await store.Create(CreateDecisionViewRequest( - requestId: "req-resolution", - decisions: - [ - MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution( - MutationRequestVersionResolutionDecisionType.RejectedAsStale), - MutationContext.User("resolver", "Resolver", "Rejected as stale")) - with - { - Timestamp = new DateTimeOffset(2026, 6, 3, 12, 0, 0, TimeSpan.Zero) - } - ])); - - await store.Create(CreateDecisionViewRequest( - requestId: "req-executed", - decisions: - [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), - MutationContext.System("Executed")) - with - { - Timestamp = new DateTimeOffset(2026, 6, 3, 13, 0, 0, TimeSpan.Zero) - } - ])); - - var decisions = await store.GetRecentDecisionsAsync( - MutationRequestDecisionQuery.RecentVersionResolutions() with - { - ActorIds = new HashSet { "resolver" } - }, - take: 5); - - Assert.Single(decisions); - Assert.Equal("req-resolution", decisions[0].Request.RequestId); - Assert.Equal(MutationRequestDecisionCategory.VersionResolution, decisions[0].Decision.Type.Category); - } - - private static MutationRequest CreateSimpleRequest( - string requestId, - MutationRequestStatus status, - PendingMutationReason? pendingReason, - DateTimeOffset createdAt) - => MutationRequestFactory.Pending( - stateId: "tenant-42:quota", - stateType: "QuotaState", - mutationType: "IncreaseQuotaMutation", - intent: new MutationIntent - { - OperationName = "IncreaseQuota", - Category = "Billing", - Description = "Raise quota", - Tags = new HashSet { "billing" }, - EstimatedBlastRadius = BlastRadius.Single - }, - context: MutationContext.User("alice", "Alice", "Need more quota"), - pendingReason: pendingReason ?? PendingMutationReason.Approval) - with - { - RequestId = requestId, - Lifecycle = new MutationRequestLifecycleDetails - { - Status = status, - PendingReason = pendingReason, - CreatedAt = createdAt, - UpdatedAt = createdAt - } - }; - - private static MutationRequest CreateApprovedRequest( - string requestId, - DateTimeOffset createdAt, - DateTimeOffset updatedAt) - => MutationRequestFactory.PendingApproval( - stateId: "tenant-42:roles", - stateType: "IamRoleState", - mutationType: "GrantRoleMutation", - intent: new MutationIntent - { - OperationName = "GrantRole", - Category = "Security", - Description = "Grant elevated access", - Tags = new HashSet { "security" }, - EstimatedBlastRadius = BlastRadius.Module - }, - context: MutationContext.User("requester", "Requester", "Need access"), - requirements: - [ - PolicyRequirement.Approval("approver", "Review elevated access") - ]) - with - { - RequestId = requestId, - Lifecycle = new MutationRequestLifecycleDetails - { - Status = MutationRequestStatus.Approved, - PendingReason = null, - CreatedAt = createdAt, - UpdatedAt = updatedAt - }, - Decisions = - [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), - MutationContext.User("requester", "Requester", "Submitted")) - with - { - Timestamp = createdAt - }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), - MutationContext.User("requester", "Requester", "Pending approval")) - with - { - Timestamp = createdAt.AddMinutes(5) - }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), - MutationContext.User("requester", "Requester", "Approval requested"), - metadata: new Dictionary { ["Queue"] = "security" }) - with - { - Timestamp = createdAt.AddMinutes(10) - }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted), - MutationContext.User("approver", "Approver", "Approved"), - metadata: new Dictionary { ["Queue"] = "security" }) - with - { - Timestamp = updatedAt - }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), - MutationContext.User("approver", "Approver", "Approved"), - metadata: new Dictionary { ["Queue"] = "security" }) - with - { - Timestamp = updatedAt.AddMinutes(1) - } - ] - }; - - private static MutationRequest CreateApprovalViewRequest( - string requestId, - string approverId, - string approverRole, - string approverGroup, - string category, - MutationApprovalRequirementStatus approvalStatus) - => MutationRequestFactory.PendingApproval( - stateId: "tenant-42:roles", - stateType: "IamRoleState", - mutationType: "GrantRoleMutation", - intent: new MutationIntent - { - OperationName = "GrantRole", - Category = category, - Tags = new HashSet { "approval" } - }, - context: MutationContext.User("requester", "Requester", "Need approval"), - requirements: - [ - PolicyRequirement.Approval(approverId, "Need review") - ]) - with - { - RequestId = requestId, - Lifecycle = new MutationRequestLifecycleDetails - { - Status = MutationRequestStatus.Pending, - PendingReason = PendingMutationReason.Approval - }, - ApprovalRequirements = - [ - new MutationApprovalRequirement - { - ApproverId = approverId, - ApproverRole = approverRole, - ApproverGroup = approverGroup, - Status = approvalStatus, - StepOrder = 1 - } - ] - }; - - private static MutationRequest CreateDecisionViewRequest( - string requestId, - IReadOnlyList decisions) - => MutationRequestFactory.Pending( - stateId: "tenant-42:roles", - stateType: "IamRoleState", - mutationType: "GrantRoleMutation", - intent: new MutationIntent - { - OperationName = "GrantRole", - Category = "Security" - }, - context: MutationContext.User("requester", "Requester", "Need execution"), - pendingReason: PendingMutationReason.ExternalCheck) - with - { - RequestId = requestId, - Decisions = decisions, - Lifecycle = new MutationRequestLifecycleDetails - { - Status = MutationRequestStatus.Pending, - PendingReason = PendingMutationReason.ExternalCheck, - UpdatedAt = decisions.Max(decision => decision.Timestamp) - } - }; - - private static MutationRequest CreateGovernedRequest( - string requestId, - string stateId, - string stateType, - string mutationType, - string actorId, - string actorName, - string category, - IReadOnlySet tags, - IReadOnlyDictionary intentMetadata, - IReadOnlyDictionary requestMetadata, - BlastRadius blastRadius, - DateTimeOffset createdAt, - DateTimeOffset updatedAt, - MutationRequestStatus status, - PendingMutationReason? pendingReason, - IReadOnlyList decisions, - IReadOnlyList? sideEffects = null) - => new MutationRequest - { - RequestId = requestId, - Scope = new MutationRequestScopeDetails - { - StateId = stateId, - StateType = stateType, - MutationType = mutationType - }, - Payload = new MutationRequestPayloadDetails - { - Intent = new MutationIntent - { - OperationName = mutationType, - Category = category, - Tags = tags, - Metadata = intentMetadata, - EstimatedBlastRadius = blastRadius - }, - Context = MutationContext.User(actorId, actorName, "Query test") - }, - Lifecycle = new MutationRequestLifecycleDetails - { - Status = status, - PendingReason = pendingReason, - CreatedAt = createdAt, - UpdatedAt = updatedAt - }, - Decisions = decisions, - Metadata = requestMetadata, - SideEffects = sideEffects ?? [] - }; - - [SideEffectDataContract("governance.side-effect", 1)] - private sealed record GovernanceSideEffectData - { - public required string Reference { get; init; } - } -} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreViewsTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreViewsTests.cs new file mode 100644 index 0000000..7772b73 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreViewsTests.cs @@ -0,0 +1,200 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Intent; +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.Queries.Model.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Runtime.Storage; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Queries.Builders; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Queries.Model; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Queries; + +public sealed partial class MutationRequestQueryStoreTests +{ + [Fact] + public async Task QueryAsync_can_filter_by_persisted_side_effect_dimensions() + { + var store = new InMemoryMutationRequestStore(); + + await store.Create(MutationRequestQueryStoreRequestBuilders.CreateGovernedRequest( + requestId: "req-side-effect", + 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.Executed, + pendingReason: null, + decisions: [], + sideEffects: + [ + SideEffect.Critical( + type: "WorkflowRejected", + description: "Manual review required", + data: new GovernanceSideEffectData + { + Reference = "INC-42" + }) + ])); + + await store.Create(MutationRequestQueryStoreRequestBuilders.CreateGovernedRequest( + requestId: "req-other-effect", + 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.Executed, + pendingReason: null, + decisions: [], + sideEffects: + [ + SideEffect.Create( + type: "QuotaRaised", + description: "Quota updated") + ])); + + var results = await store.QueryAsync(new MutationRequestQuery + { + SideEffects = new MutationRequestSideEffectFilter + { + Types = new HashSet { "WorkflowRejected" }, + DataContractTypes = new HashSet { "governance.side-effect" }, + Severities = new HashSet { SideEffectSeverity.Critical }, + RequiresAction = true + } + }); + + Assert.Single(results); + Assert.Equal("req-side-effect", results[0].RequestId); + } + + [Fact] + public async Task GetPendingApprovalQueueAsync_and_GetRecentApprovalsAsync_return_approval_oriented_views() + { + var store = new InMemoryMutationRequestStore(); + + var pendingApproval = await store.Create(MutationRequestQueryStoreRequestBuilders.CreateSimpleRequest( + "req-pending-approval", + MutationRequestStatus.Pending, + PendingMutationReason.Approval, + new DateTimeOffset(2026, 6, 1, 8, 0, 0, TimeSpan.Zero))); + + var recentApproval = await store.Create(MutationRequestQueryStoreRequestBuilders.CreateApprovedRequest( + "req-recent-approval", + new DateTimeOffset(2026, 6, 2, 8, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 6, 2, 9, 15, 0, TimeSpan.Zero))); + + await store.Create(MutationRequestQueryStoreRequestBuilders.CreateApprovedRequest( + "req-older-approval", + new DateTimeOffset(2026, 6, 1, 8, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero))); + + var pendingQueue = await store.GetPendingApprovalQueueAsync(); + var recentApprovals = await store.GetRecentApprovalsAsync(take: 1); + + Assert.Single(pendingQueue); + Assert.Equal(pendingApproval.RequestId, pendingQueue[0].RequestId); + + Assert.Single(recentApprovals); + Assert.Equal(recentApproval.RequestId, recentApprovals[0].RequestId); + } + + [Fact] + public async Task GetPendingApprovalsAsync_filters_approval_views_by_approver_dimensions() + { + var store = new InMemoryMutationRequestStore(); + + await store.Create(MutationRequestQueryStoreViewBuilders.CreateApprovalViewRequest( + requestId: "req-security", + approverId: "security-lead", + approverRole: "SecurityLead", + approverGroup: "security", + category: "Security", + approvalStatus: MutationApprovalRequirementStatus.Pending)); + + await store.Create(MutationRequestQueryStoreViewBuilders.CreateApprovalViewRequest( + requestId: "req-platform", + approverId: "platform-owner", + approverRole: "PlatformOwner", + approverGroup: "platform", + category: "Platform", + approvalStatus: MutationApprovalRequirementStatus.Pending)); + + var approvals = await store.GetPendingApprovalsAsync(new MutationApprovalQuery + { + ApproverIds = new HashSet { "security-lead" }, + ApproverRoles = new HashSet { "SecurityLead" }, + ApproverGroups = new HashSet { "security" }, + Categories = new HashSet { "Security" } + }); + + Assert.Single(approvals); + Assert.Equal("req-security", approvals[0].Request.RequestId); + Assert.Equal("security-lead", approvals[0].Approval.ApproverId); + } + + [Fact] + public async Task GetRecentDecisionsAsync_returns_filtered_decision_views() + { + var store = new InMemoryMutationRequestStore(); + + await store.Create(MutationRequestQueryStoreViewBuilders.CreateDecisionViewRequest( + requestId: "req-resolution", + decisions: + [ + MutationRequestDecision.Create( + MutationRequestDecisionType.VersionResolution( + MutationRequestVersionResolutionDecisionType.RejectedAsStale), + MutationContext.User("resolver", "Resolver", "Rejected as stale")) + with + { + Timestamp = new DateTimeOffset(2026, 6, 3, 12, 0, 0, TimeSpan.Zero) + } + ])); + + await store.Create(MutationRequestQueryStoreViewBuilders.CreateDecisionViewRequest( + requestId: "req-executed", + decisions: + [ + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), + MutationContext.System("Executed")) + with + { + Timestamp = new DateTimeOffset(2026, 6, 3, 13, 0, 0, TimeSpan.Zero) + } + ])); + + var decisions = await store.GetRecentDecisionsAsync( + MutationRequestDecisionQuery.RecentVersionResolutions() with + { + ActorIds = new HashSet { "resolver" } + }, + take: 5); + + Assert.Single(decisions); + Assert.Equal("req-resolution", decisions[0].Request.RequestId); + Assert.Equal(MutationRequestDecisionCategory.VersionResolution, decisions[0].Decision.Type.Category); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs index 2545cf9..27e8548 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs @@ -2,12 +2,11 @@ using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; using ModularityKit.Mutator.Governance.Runtime.Resolution.Execution; using ModularityKit.Mutator.Governance.Runtime.Storage; -using ModularityKit.Mutator.Governance.Tests.TestSupport; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Requests; using Xunit; namespace ModularityKit.Mutator.Governance.Tests.Resolution; diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Approval/Workflow/MutationRequestApprovalWorkflowTestSupport.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Approval/Workflow/MutationRequestApprovalWorkflowTestSupport.cs new file mode 100644 index 0000000..905424f --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Approval/Workflow/MutationRequestApprovalWorkflowTestSupport.cs @@ -0,0 +1,190 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Tests.TestSupport.Approval.Workflow; + +/// +/// Builds mutation requests and contexts used by approval workflow tests. +/// +internal static class MutationRequestApprovalWorkflowTestSupport +{ + /// + /// Creates a request that requires a single approval path. + /// + public static MutationRequest CreateLinearApprovalRequest() + { + return MutationRequestFactory.PendingApproval( + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + intent: CreateIntent(), + context: MutationContext.User("requester", "Requester", "Needs privileged access"), + requirements: + [ + PolicyRequirement.Approval("alice", "Manager approval") + ], + expectedStateVersion: "v10"); + } + + /// + /// Creates a request that exercises quorum-based approval requirements. + /// + public static MutationRequest CreateQuorumApprovalRequest() + { + return MutationRequestFactory.PendingApproval( + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + intent: CreateIntent(), + context: MutationContext.User("requester", "Requester", "Needs privileged access"), + requirements: + [ + PolicyRequirement.Approval("alice", "Manager approval"), + new PolicyRequirement + { + Type = "Approval", + Description = "Security quorum", + Data = new + { + Approvers = new[] { "bob", "carol", "dave" }, + StepOrder = 2, + ApprovalGroupId = "security-quorum", + Quorum = 2, + Reason = "Security sign-off" + } + }, + new PolicyRequirement + { + Type = "Approval", + Description = "Finance role approval", + Data = new + { + ApproverRole = "finance-approver", + StepOrder = 3, + Reason = "Finance sign-off" + } + } + ], + expectedStateVersion: "v10"); + } + + /// + /// Creates a request that mixes role and group approval requirements. + /// + public static MutationRequest CreateRoleAndGroupApprovalRequest() + { + return MutationRequestFactory.PendingApproval( + stateId: "tenant-42:deploy", + stateType: "DeploymentState", + mutationType: "ApproveDeploymentMutation", + intent: CreateIntent(), + context: MutationContext.User("requester", "Requester", "Need deployment approval"), + requirements: + [ + new PolicyRequirement + { + Type = "Approval", + Description = "Security role approval", + Data = new + { + ApproverRole = "security-admin", + StepOrder = 1, + Reason = "Security review" + } + }, + new PolicyRequirement + { + Type = "Approval", + Description = "Operations group approval", + Data = new + { + ApproverGroup = "ops-oncall", + StepOrder = 2, + Reason = "Operational readiness" + } + } + ], + expectedStateVersion: "v7"); + } + + /// + /// Creates a request whose approval requirements are already expired. + /// + public static MutationRequest CreateExpiredApprovalRequest() + { + return MutationRequestFactory.PendingApproval( + stateId: "tenant-42:billing", + stateType: "BillingState", + mutationType: "IncreaseQuotaMutation", + intent: CreateIntent(), + context: MutationContext.User("requester", "Requester", "Need urgent quota increase"), + requirements: + [ + new PolicyRequirement + { + Type = "Approval", + Description = "Manager approval", + Data = new + { + Approver = "alice", + StepOrder = 1, + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5), + Reason = "Manager sign-off" + } + } + ], + expectedStateVersion: "v5"); + } + + /// + /// Creates a user context that carries role metadata for approval tests. + /// + public static MutationContext CreateRoleContext( + string actorId, + string actorName, + string reason, + params string[] roles) + { + return MutationContext.User(actorId, actorName, reason) with + { + Metadata = new Dictionary + { + ["ActorRoles"] = roles + } + }; + } + + /// + /// Creates a user context that carries group metadata for approval tests. + /// + public static MutationContext CreateGroupContext( + string actorId, + string actorName, + string reason, + params string[] groups) + { + return MutationContext.User(actorId, actorName, reason) with + { + Metadata = new Dictionary + { + ["ActorGroups"] = groups + } + }; + } + + /// + /// Creates the baseline intent used by approval workflow scenarios. + /// + public static MutationIntent CreateIntent() + { + return new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated role to tenant operator" + }; + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Effects/GovernanceExecutionSideEffectData.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Effects/GovernanceExecutionSideEffectData.cs new file mode 100644 index 0000000..626932d --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Effects/GovernanceExecutionSideEffectData.cs @@ -0,0 +1,20 @@ +using ModularityKit.Mutator.Abstractions.Effects; + +namespace ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Effects; + +/// +/// Side-effect payload used by governed execution tests. +/// +[SideEffectDataContract("governance.execution-effect")] +internal sealed record GovernanceExecutionSideEffectData +{ + /// + /// Gets the state identifier associated with the governed request. + /// + public required string RequestStateId { get; init; } + + /// + /// Gets the resulting role value produced by the test mutation. + /// + public required string NewRole { get; init; } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Host/GovernanceExecutionManagerTestSupport.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Host/GovernanceExecutionManagerTestSupport.cs new file mode 100644 index 0000000..19c1775 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Host/GovernanceExecutionManagerTestSupport.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; +using ModularityKit.Mutator.Abstractions; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.History; +using ModularityKit.Mutator.Governance.Runtime.Execution.Orchestration; +using ModularityKit.Mutator.Governance.Runtime.Resolution.Execution; +using ModularityKit.Mutator.Governance.Runtime.Storage; +using ModularityKit.Mutator.Abstractions.Audit; +using ModularityKit.Mutator.Runtime; + +namespace ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Host; + +/// +/// Creates governed execution fixtures for execution-oriented tests. +/// +internal static class GovernanceExecutionManagerTestSupport +{ + /// + /// Builds the service provider and execution manager used by governed execution tests. + /// + public static async Task<(ServiceProvider Provider, IMutationEngine Engine, IMutationAuditor Auditor, IMutationHistoryStore HistoryStore, InMemoryMutationRequestStore RequestStore, MutationRequestVersionResolutionManager ResolutionManager, GovernanceExecutionManager ExecutionManager)> CreateAsync() + { + var services = new ServiceCollection(); + services.AddMutators(MutationEngineOptions.Strict); + + var provider = services.BuildServiceProvider(); + var engine = provider.GetRequiredService(); + var auditor = provider.GetRequiredService(); + var historyStore = provider.GetRequiredService(); + var requestStore = new InMemoryMutationRequestStore(); + var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); + var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); + + return (provider, engine, auditor, historyStore, requestStore, resolutionManager, executionManager); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Model/RoleState.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Model/RoleState.cs new file mode 100644 index 0000000..edb244b --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Model/RoleState.cs @@ -0,0 +1,14 @@ +using ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts; + +namespace ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Model; + +/// +/// Versioned role state used by governed execution scenarios. +/// +internal sealed record RoleState(string StateId, string Role, string Version) : IVersionedState +{ + /// + /// Creates a role state with explicit identifiers and version. + /// + public static RoleState Create(string stateId, string role, string version) => new(stateId, role, version); +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Mutations/PromoteRoleMutation.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Mutations/PromoteRoleMutation.cs new file mode 100644 index 0000000..95aeb4c --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Mutations/PromoteRoleMutation.cs @@ -0,0 +1,66 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Changes; +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Effects; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Model; + +namespace ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Mutations; + +/// +/// Promotes a role to Admin for execution tests. +/// +internal sealed class PromoteRoleMutation(MutationContext context, string nextVersion) : IMutation +{ + /// + /// Gets the intent associated with the promotion mutation. + /// + public MutationIntent Intent { get; } = new() + { + OperationName = "PromoteRole", + Category = "Security", + Description = "Promote tenant role after governance approval" + }; + + /// + /// Gets the invocation context for the mutation. + /// + public MutationContext Context { get; } = context; + + /// + public MutationResult Apply(RoleState state) + { + var newState = state with + { + Role = "Admin", + Version = nextVersion + }; + + return MutationResult.Success( + newState, + ChangeSet.Single(StateChange.Modified("Role", state.Role, newState.Role)), + [ + SideEffect.Create( + type: "RoleElevated", + description: "Governed execution elevated the role", + data: new GovernanceExecutionSideEffectData + { + RequestStateId = state.StateId, + NewRole = newState.Role + }) + ]); + } + + /// + public ValidationResult Validate(RoleState state) + { + return state.Role == "Admin" + ? ValidationResult.WithError("Role", "Role is already Admin.") + : ValidationResult.Success(); + } + + /// + public MutationResult Simulate(RoleState state) => Apply(state); +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Mutations/RollbackRoleMutation.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Mutations/RollbackRoleMutation.cs new file mode 100644 index 0000000..264749a --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Execution/Mutations/RollbackRoleMutation.cs @@ -0,0 +1,67 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Changes; +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Effects; +using ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Model; + +namespace ModularityKit.Mutator.Governance.Tests.TestSupport.Execution.Mutations; + +/// +/// Rolls a role back to Reader for compensation scenarios. +/// +internal sealed class RollbackRoleMutation(MutationContext context, string nextVersion) : IMutation +{ + /// + /// Gets the intent associated with the rollback mutation. + /// + public MutationIntent Intent { get; } = new() + { + OperationName = "RollbackRole", + Category = "Security", + Description = "Rollback tenant role to Reader", + IsReversible = false + }; + + /// + /// Gets the invocation context for the mutation. + /// + public MutationContext Context { get; } = context; + + /// + public MutationResult Apply(RoleState state) + { + var newState = state with + { + Role = "Reader", + Version = nextVersion + }; + + return MutationResult.Success( + newState, + ChangeSet.Single(StateChange.Modified("Role", state.Role, newState.Role)), + [ + SideEffect.Create( + type: "RoleRollback", + description: "Governed compensation restored the previous role", + data: new GovernanceExecutionSideEffectData + { + RequestStateId = state.StateId, + NewRole = newState.Role + }) + ]); + } + + /// + public ValidationResult Validate(RoleState state) + { + return state.Role == "Reader" + ? ValidationResult.WithError("Role", "Role is already Reader.") + : ValidationResult.Success(); + } + + /// + public MutationResult Simulate(RoleState state) => Apply(state); +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Lifecycle/Storage/StaleSnapshotMutationRequestStore.cs similarity index 83% rename from Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs rename to Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Lifecycle/Storage/StaleSnapshotMutationRequestStore.cs index e7be126..aea1777 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Lifecycle/Storage/StaleSnapshotMutationRequestStore.cs @@ -2,17 +2,26 @@ using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Storage; -namespace ModularityKit.Mutator.Governance.Tests.TestSupport; +namespace ModularityKit.Mutator.Governance.Tests.TestSupport.Lifecycle.Storage; +/// +/// Stores a stale request snapshot to exercise optimistic concurrency scenarios. +/// internal sealed class StaleSnapshotMutationRequestStore(MutationRequest seedRequest) : IMutationRequestStore { - private readonly object _gate = new(); + private readonly Lock _gate = new(); private readonly MutationRequest _seedRequest = seedRequest; private readonly List _getSnapshots = []; private MutationRequest _current = seedRequest; + /// + /// Gets the number of store attempts observed by the test double. + /// public int StoreCount { get; private set; } + /// + /// Gets the current in-memory request snapshot. + /// public MutationRequest Current { get @@ -24,6 +33,9 @@ public MutationRequest Current } } + /// + /// Gets the request snapshots returned by . + /// public IReadOnlyList GetSnapshots => _getSnapshots; public Task Create( diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Queries/Builders/MutationRequestQueryStoreRequestBuilders.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Queries/Builders/MutationRequestQueryStoreRequestBuilders.cs new file mode 100644 index 0000000..4315d9b --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Queries/Builders/MutationRequestQueryStoreRequestBuilders.cs @@ -0,0 +1,181 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Tests.TestSupport.Queries.Builders; + +/// +/// Builds request fixtures used by query store tests. +/// +internal static class MutationRequestQueryStoreRequestBuilders +{ + /// + /// Creates a simple request fixture with configurable lifecycle state. + /// + public static MutationRequest CreateSimpleRequest( + string requestId, + MutationRequestStatus status, + PendingMutationReason? pendingReason, + DateTimeOffset createdAt) + => MutationRequestFactory.Pending( + stateId: "tenant-42:quota", + stateType: "QuotaState", + mutationType: "IncreaseQuotaMutation", + intent: new MutationIntent + { + OperationName = "IncreaseQuota", + Category = "Billing", + Description = "Raise quota", + Tags = new HashSet { "billing" }, + EstimatedBlastRadius = BlastRadius.Single + }, + context: MutationContext.User("alice", "Alice", "Need more quota"), + pendingReason: pendingReason ?? PendingMutationReason.Approval) + with + { + RequestId = requestId, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = status, + PendingReason = pendingReason, + CreatedAt = createdAt, + UpdatedAt = createdAt + } + }; + + /// + /// Creates an approved request fixture with a decision timeline. + /// + public static MutationRequest CreateApprovedRequest( + string requestId, + DateTimeOffset createdAt, + DateTimeOffset updatedAt) + => MutationRequestFactory.PendingApproval( + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated access", + Tags = new HashSet { "security" }, + EstimatedBlastRadius = BlastRadius.Module + }, + context: MutationContext.User("requester", "Requester", "Need access"), + requirements: + [ + PolicyRequirement.Approval("approver", "Review elevated access") + ]) + with + { + RequestId = requestId, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Approved, + PendingReason = null, + CreatedAt = createdAt, + UpdatedAt = updatedAt + }, + Decisions = + [ + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationContext.User("requester", "Requester", "Submitted")) + with + { + Timestamp = createdAt + }, + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), + MutationContext.User("requester", "Requester", "Pending approval")) + with + { + Timestamp = createdAt.AddMinutes(5) + }, + MutationRequestDecision.Create( + MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), + MutationContext.User("requester", "Requester", "Approval requested"), + metadata: new Dictionary { ["Queue"] = "security" }) + with + { + Timestamp = createdAt.AddMinutes(10) + }, + MutationRequestDecision.Create( + MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted), + MutationContext.User("approver", "Approver", "Approved"), + metadata: new Dictionary { ["Queue"] = "security" }) + with + { + Timestamp = updatedAt + }, + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + MutationContext.User("approver", "Approver", "Approved"), + metadata: new Dictionary { ["Queue"] = "security" }) + with + { + Timestamp = updatedAt.AddMinutes(1) + } + ] + }; + + /// + /// Creates a governed request fixture with fully populated request data. + /// + public static MutationRequest CreateGovernedRequest( + string requestId, + string stateId, + string stateType, + string mutationType, + string actorId, + string actorName, + string category, + IReadOnlySet tags, + IReadOnlyDictionary intentMetadata, + IReadOnlyDictionary requestMetadata, + BlastRadius blastRadius, + DateTimeOffset createdAt, + DateTimeOffset updatedAt, + MutationRequestStatus status, + PendingMutationReason? pendingReason, + IReadOnlyList decisions, + IReadOnlyList? sideEffects = null) + => new MutationRequest + { + RequestId = requestId, + Scope = new MutationRequestScopeDetails + { + StateId = stateId, + StateType = stateType, + MutationType = mutationType + }, + Payload = new MutationRequestPayloadDetails + { + Intent = new MutationIntent + { + OperationName = mutationType, + Category = category, + Tags = tags, + Metadata = intentMetadata, + EstimatedBlastRadius = blastRadius + }, + Context = MutationContext.User(actorId, actorName, "Query test") + }, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = status, + PendingReason = pendingReason, + CreatedAt = createdAt, + UpdatedAt = updatedAt + }, + Decisions = decisions, + Metadata = requestMetadata, + SideEffects = sideEffects ?? [] + }; +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Queries/Builders/MutationRequestQueryStoreViewBuilders.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Queries/Builders/MutationRequestQueryStoreViewBuilders.cs new file mode 100644 index 0000000..4589dea --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Queries/Builders/MutationRequestQueryStoreViewBuilders.cs @@ -0,0 +1,91 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Tests.TestSupport.Queries.Builders; + +/// +/// Builds approval and decision views used by query store tests. +/// +internal static class MutationRequestQueryStoreViewBuilders +{ + /// + /// Creates an approval view fixture with the requested approval status. + /// + public static MutationRequest CreateApprovalViewRequest( + string requestId, + string approverId, + string approverRole, + string approverGroup, + string category, + MutationApprovalRequirementStatus approvalStatus) + => MutationRequestFactory.PendingApproval( + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = category, + Tags = new HashSet { "approval" } + }, + context: MutationContext.User("requester", "Requester", "Need approval"), + requirements: + [ + PolicyRequirement.Approval(approverId, "Need review") + ]) + with + { + RequestId = requestId, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval + }, + ApprovalRequirements = + [ + new MutationApprovalRequirement + { + ApproverId = approverId, + ApproverRole = approverRole, + ApproverGroup = approverGroup, + Status = approvalStatus, + StepOrder = 1 + } + ] + }; + + /// + /// Creates a decision view fixture with the supplied decision history. + /// + public static MutationRequest CreateDecisionViewRequest( + string requestId, + IReadOnlyList decisions) + => MutationRequestFactory.Pending( + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security" + }, + context: MutationContext.User("requester", "Requester", "Need execution"), + pendingReason: PendingMutationReason.ExternalCheck) + with + { + RequestId = requestId, + Decisions = decisions, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.ExternalCheck, + UpdatedAt = decisions.Max(decision => decision.Timestamp) + } + }; +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Queries/Model/GovernanceSideEffectData.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Queries/Model/GovernanceSideEffectData.cs new file mode 100644 index 0000000..b88ceb3 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Queries/Model/GovernanceSideEffectData.cs @@ -0,0 +1,15 @@ +using ModularityKit.Mutator.Abstractions.Effects; + +namespace ModularityKit.Mutator.Governance.Tests.TestSupport.Queries.Model; + +/// +/// Query-side side-effect payload used by governance query scenarios. +/// +[SideEffectDataContract("governance.side-effect")] +internal sealed record GovernanceSideEffectData +{ + /// + /// Gets the external reference carried by the side effect. + /// + public required string Reference { get; init; } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Requests/MutationRequestTestFactory.cs similarity index 80% rename from Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs rename to Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Requests/MutationRequestTestFactory.cs index da635c0..faa648c 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/Requests/MutationRequestTestFactory.cs @@ -4,10 +4,16 @@ using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Tests.TestSupport; +namespace ModularityKit.Mutator.Governance.Tests.TestSupport.Requests; +/// +/// Creates baseline mutation requests used across governance tests. +/// internal static class MutationRequestTestFactory { + /// + /// Creates a pending request with stable defaults for lifecycle tests. + /// public static MutationRequest CreatePendingRequest() { return MutationRequestFactory.Pending( @@ -25,6 +31,9 @@ public static MutationRequest CreatePendingRequest() expectedStateVersion: "v12"); } + /// + /// Creates an approved request with security-oriented defaults. + /// public static MutationRequest CreateApprovedSecurityRequest(string expectedStateVersion) { return MutationRequestFactory.Approved( From f557860401b2817ffda838c694c50553a0751dd2 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Tue, 30 Jun 2026 21:21:19 +0200 Subject: [PATCH 17/17] test(redis): split keyspace scenarios --- ...MutationRequestKeyspaceEnumerationTests.cs | 39 ++++++++ ...isMutationRequestKeyspaceIndexKeysTests.cs | 22 +++++ ...MutationRequestKeyspaceRequestKeysTests.cs | 27 ++++++ .../Keys/RedisMutationRequestKeyspaceTests.cs | 73 --------------- ...yKit.Mutator.Governance.Redis.Tests.csproj | 2 + .../RedisMutationRequestSerializerTests.cs | 79 +---------------- ...RedisMutationRequestKeyspaceTestSupport.cs | 19 ++++ .../Models/RedisGovernanceSideEffectData.cs | 15 ++++ ...MutationRequestSerializerRequestFactory.cs | 88 +++++++++++++++++++ 9 files changed, 215 insertions(+), 149 deletions(-) create mode 100644 Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceEnumerationTests.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceIndexKeysTests.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceRequestKeysTests.cs delete mode 100644 Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceTests.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Redis.Tests/TestSupport/Keys/RedisMutationRequestKeyspaceTestSupport.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Redis.Tests/TestSupport/Serialization/Models/RedisGovernanceSideEffectData.cs create mode 100644 Tests/ModularityKit.Mutator.Governance.Redis.Tests/TestSupport/Serialization/RedisMutationRequestSerializerRequestFactory.cs diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceEnumerationTests.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceEnumerationTests.cs new file mode 100644 index 0000000..9a364e0 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceEnumerationTests.cs @@ -0,0 +1,39 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Tests.TestSupport.Keys; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Redis.Tests.Keys; + +public sealed partial class RedisMutationRequestKeyspaceTests +{ + [Fact] + public void Enumerate_indexes_includes_pending_indexes_only_for_pending_requests() + { + var keyspace = RedisMutationRequestKeyspaceTestSupport.CreateKeyspace(); + + var request = new MutationRequest + { + RequestId = "req-42", + Scope = new MutationRequestScopeDetails + { + StateId = "tenant-42", + StateType = "IamRoleState", + MutationType = "GrantRoleMutation" + }, + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval + } + }; + + var keys = keyspace.EnumerateIndexes(request).Select(key => key.ToString()).ToArray(); + + Assert.Contains("mk:gov:requests:ids", keys); + Assert.Contains("mk:gov:states:tenant-42:requests", keys); + Assert.Contains("mk:gov:status:pending:requests", keys); + Assert.Contains("mk:gov:pending:requests", keys); + Assert.Contains("mk:gov:pending:approval:requests", keys); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceIndexKeysTests.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceIndexKeysTests.cs new file mode 100644 index 0000000..8be43f6 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceIndexKeysTests.cs @@ -0,0 +1,22 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Tests.TestSupport.Keys; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Redis.Tests.Keys; + +public sealed partial class RedisMutationRequestKeyspaceTests +{ + [Fact] + public void Builds_expected_index_keys_for_state_status_and_pending_reason() + { + var keyspace = RedisMutationRequestKeyspaceTestSupport.CreateKeyspace(); + + Assert.Equal("mk:gov:states:tenant-42:requests", keyspace.RequestsByStateId("tenant-42").ToString()); + Assert.Equal("mk:gov:status:pending:requests", keyspace.RequestsByStatus(MutationRequestStatus.Pending).ToString()); + Assert.Equal("mk:gov:pending:requests", keyspace.PendingRequestIds().ToString()); + Assert.Equal( + "mk:gov:pending:approval:requests", + keyspace.PendingRequestIds(PendingMutationReason.Approval).ToString()); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceRequestKeysTests.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceRequestKeysTests.cs new file mode 100644 index 0000000..2ca3ee4 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceRequestKeysTests.cs @@ -0,0 +1,27 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis; +using ModularityKit.Mutator.Governance.Redis.Keys; +using ModularityKit.Mutator.Governance.Redis.Tests.TestSupport.Keys; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Redis.Tests.Keys; + +/// +/// Verifies Redis mutation request key construction for provider scenarios. +/// +public sealed partial class RedisMutationRequestKeyspaceTests +{ + /// + /// Verifies request data and identity keys derived from the configured prefix. + /// + [Fact] + public void Builds_expected_request_keys_from_prefix() + { + var keyspace = RedisMutationRequestKeyspaceTestSupport.CreateKeyspace(); + + Assert.Equal("mk:gov:requests:ids", keyspace.RequestIds().ToString()); + Assert.Equal("mk:gov:requests:req-42:data", keyspace.RequestData("req-42").ToString()); + Assert.Equal("mk:gov:requests:req-42:revision", keyspace.RequestRevision("req-42").ToString()); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceTests.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceTests.cs deleted file mode 100644 index 8ce6a56..0000000 --- a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceTests.cs +++ /dev/null @@ -1,73 +0,0 @@ -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -using ModularityKit.Mutator.Governance.Redis; -using ModularityKit.Mutator.Governance.Redis.Configuration; -using ModularityKit.Mutator.Governance.Redis.Keys; -using Xunit; - -namespace ModularityKit.Mutator.Governance.Redis.Tests.Keys; - -public sealed class RedisMutationRequestKeyspaceTests -{ - [Fact] - public void Builds_expected_request_keys_from_prefix() - { - var keyspace = new RedisMutationRequestKeyspace(new RedisMutationRequestStoreOptions - { - KeyPrefix = "mk:gov" - }); - - Assert.Equal("mk:gov:requests:ids", keyspace.RequestIds().ToString()); - Assert.Equal("mk:gov:requests:req-42:data", keyspace.RequestData("req-42").ToString()); - Assert.Equal("mk:gov:requests:req-42:revision", keyspace.RequestRevision("req-42").ToString()); - } - - [Fact] - public void Builds_expected_index_keys_for_state_status_and_pending_reason() - { - var keyspace = new RedisMutationRequestKeyspace(new RedisMutationRequestStoreOptions - { - KeyPrefix = "mk:gov" - }); - - Assert.Equal("mk:gov:states:tenant-42:requests", keyspace.RequestsByStateId("tenant-42").ToString()); - Assert.Equal("mk:gov:status:pending:requests", keyspace.RequestsByStatus(MutationRequestStatus.Pending).ToString()); - Assert.Equal("mk:gov:pending:requests", keyspace.PendingRequestIds().ToString()); - Assert.Equal( - "mk:gov:pending:approval:requests", - keyspace.PendingRequestIds(PendingMutationReason.Approval).ToString()); - } - - [Fact] - public void Enumerate_indexes_includes_pending_indexes_only_for_pending_requests() - { - var keyspace = new RedisMutationRequestKeyspace(new RedisMutationRequestStoreOptions - { - KeyPrefix = "mk:gov" - }); - - var request = new MutationRequest - { - RequestId = "req-42", - Scope = new MutationRequestScopeDetails - { - StateId = "tenant-42", - StateType = "IamRoleState", - MutationType = "GrantRoleMutation" - }, - Lifecycle = new MutationRequestLifecycleDetails - { - Status = MutationRequestStatus.Pending, - PendingReason = PendingMutationReason.Approval - } - }; - - var keys = keyspace.EnumerateIndexes(request).Select(key => key.ToString()).ToArray(); - - Assert.Contains("mk:gov:requests:ids", keys); - Assert.Contains("mk:gov:states:tenant-42:requests", keys); - Assert.Contains("mk:gov:status:pending:requests", keys); - Assert.Contains("mk:gov:pending:requests", keys); - Assert.Contains("mk:gov:pending:approval:requests", keys); - } -} diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/ModularityKit.Mutator.Governance.Redis.Tests.csproj b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/ModularityKit.Mutator.Governance.Redis.Tests.csproj index e09acff..aad0e88 100644 --- a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/ModularityKit.Mutator.Governance.Redis.Tests.csproj +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/ModularityKit.Mutator.Governance.Redis.Tests.csproj @@ -5,6 +5,8 @@ enable enable false + true + $(NoWarn);1591 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 e548bce..3232512 100644 --- a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs @@ -1,11 +1,8 @@ -using ModularityKit.Mutator.Abstractions.Context; -using ModularityKit.Mutator.Abstractions.Effects; using ModularityKit.Mutator.Abstractions.Intent; -using ModularityKit.Mutator.Abstractions.Policies; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Redis.Serialization; +using ModularityKit.Mutator.Governance.Redis.Tests.TestSupport.Serialization; +using ModularityKit.Mutator.Governance.Redis.Tests.TestSupport.Serialization.Models; using Xunit; namespace ModularityKit.Mutator.Governance.Redis.Tests.Serialization; @@ -15,71 +12,7 @@ public sealed class RedisMutationRequestSerializerTests [Fact] public void Roundtrip_preserves_request_shape_needed_by_governance_runtime() { - var request = MutationRequestFactory.PendingApproval( - stateId: "tenant-42:roles", - stateType: "IamRoleState", - mutationType: "GrantRoleMutation", - intent: new MutationIntent - { - OperationName = "GrantRole", - Category = "Security", - Description = "Grant elevated access", - Tags = new HashSet { "security", "urgent" }, - EstimatedBlastRadius = BlastRadius.Module, - Metadata = new Dictionary - { - ["risk-owner"] = "platform" - } - }, - context: MutationContext.User("requester-1", "Requester One", "Need emergency access") with - { - StateId = "tenant-42:roles", - Metadata = new Dictionary - { - ["source"] = "tests" - } - }, - requirements: - [ - new PolicyRequirement - { - Type = "Approval", - Description = "Requires security approval", - Data = new Dictionary - { - ["Approver"] = "security-lead", - ["Reason"] = "Elevated role", - ["StepOrder"] = 1L, - ["RequiredApprovals"] = 1L - } - } - ], - expectedStateVersion: "v10", - metadata: new Dictionary - { - ["team"] = "security", - ["priority"] = "high" - }) - with - { - Lifecycle = new MutationRequestLifecycleDetails - { - Status = MutationRequestStatus.Pending, - PendingReason = PendingMutationReason.Approval, - CreatedAt = new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero), - UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero) - }, - SideEffects = - [ - SideEffect.Critical( - type: "WorkflowRejected", - description: "Workflow rejection requires action", - data: new RedisGovernanceSideEffectData - { - Ticket = "INC-42" - }) - ] - }; + var request = RedisMutationRequestSerializerRequestFactory.CreateRoundtripRequest(); var json = RedisMutationRequestSerializer.Serialize(request); var roundtrip = RedisMutationRequestSerializer.Deserialize(json); @@ -104,10 +37,4 @@ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime() Assert.Equal(request.Lifecycle.CreatedAt, roundtrip.Lifecycle.CreatedAt); Assert.Equal(request.Lifecycle.UpdatedAt, roundtrip.Lifecycle.UpdatedAt); } - - [SideEffectDataContract("redis.governance.side-effect", 1)] - private sealed record RedisGovernanceSideEffectData - { - public required string Ticket { get; init; } - } } diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/TestSupport/Keys/RedisMutationRequestKeyspaceTestSupport.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/TestSupport/Keys/RedisMutationRequestKeyspaceTestSupport.cs new file mode 100644 index 0000000..d88f413 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/TestSupport/Keys/RedisMutationRequestKeyspaceTestSupport.cs @@ -0,0 +1,19 @@ +using ModularityKit.Mutator.Governance.Redis.Configuration; +using ModularityKit.Mutator.Governance.Redis.Keys; + +namespace ModularityKit.Mutator.Governance.Redis.Tests.TestSupport.Keys; + +/// +/// Creates Redis mutation request keyspace fixtures for key-centric tests. +/// +internal static class RedisMutationRequestKeyspaceTestSupport +{ + /// + /// Creates a keyspace with the default provider prefix used by tests. + /// + public static RedisMutationRequestKeyspace CreateKeyspace(string keyPrefix = "mk:gov") + => new(new RedisMutationRequestStoreOptions + { + KeyPrefix = keyPrefix + }); +} diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/TestSupport/Serialization/Models/RedisGovernanceSideEffectData.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/TestSupport/Serialization/Models/RedisGovernanceSideEffectData.cs new file mode 100644 index 0000000..fb5324e --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/TestSupport/Serialization/Models/RedisGovernanceSideEffectData.cs @@ -0,0 +1,15 @@ +using ModularityKit.Mutator.Abstractions.Effects; + +namespace ModularityKit.Mutator.Governance.Redis.Tests.TestSupport.Serialization.Models; + +/// +/// Side-effect payload used by the serializer roundtrip fixture. +/// +[SideEffectDataContract("redis.governance.side-effect", 1)] +internal sealed record RedisGovernanceSideEffectData +{ + /// + /// Gets the external ticket reference carried by the side effect. + /// + public required string Ticket { get; init; } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/TestSupport/Serialization/RedisMutationRequestSerializerRequestFactory.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/TestSupport/Serialization/RedisMutationRequestSerializerRequestFactory.cs new file mode 100644 index 0000000..f6b7221 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/TestSupport/Serialization/RedisMutationRequestSerializerRequestFactory.cs @@ -0,0 +1,88 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Tests.TestSupport.Serialization.Models; + +namespace ModularityKit.Mutator.Governance.Redis.Tests.TestSupport.Serialization; + +/// +/// Creates the governed request fixture used by serializer tests. +/// +internal static class RedisMutationRequestSerializerRequestFactory +{ + /// + /// Creates a request that exercises the Redis serializer roundtrip path. + /// + public static MutationRequest CreateRoundtripRequest() + { + return MutationRequestFactory.PendingApproval( + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated access", + Tags = new HashSet { "security", "urgent" }, + EstimatedBlastRadius = BlastRadius.Module, + Metadata = new Dictionary + { + ["risk-owner"] = "platform" + } + }, + context: MutationContext.User("requester-1", "Requester One", "Need emergency access") with + { + StateId = "tenant-42:roles", + Metadata = new Dictionary + { + ["source"] = "tests" + } + }, + requirements: + [ + new PolicyRequirement + { + Type = "Approval", + Description = "Requires security approval", + Data = new Dictionary + { + ["Approver"] = "security-lead", + ["Reason"] = "Elevated role", + ["StepOrder"] = 1L, + ["RequiredApprovals"] = 1L + } + } + ], + expectedStateVersion: "v10", + metadata: new Dictionary + { + ["team"] = "security", + ["priority"] = "high" + }) + with + { + Lifecycle = new MutationRequestLifecycleDetails + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval, + CreatedAt = new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero), + UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero) + }, + SideEffects = + [ + SideEffect.Critical( + type: "WorkflowRejected", + description: "Workflow rejection requires action", + data: new RedisGovernanceSideEffectData + { + Ticket = "INC-42" + }) + ] + }; + } +}