diff --git a/Examples/Core/WorkflowApprovals/Contracts/WorkflowRejectedSideEffectData.cs b/Examples/Core/WorkflowApprovals/Contracts/WorkflowRejectedSideEffectData.cs new file mode 100644 index 0000000..e0aba1d --- /dev/null +++ b/Examples/Core/WorkflowApprovals/Contracts/WorkflowRejectedSideEffectData.cs @@ -0,0 +1,13 @@ +using ModularityKit.Mutator.Abstractions.Effects; + +namespace WorkflowApprovals.Contracts; + +[SideEffectDataContract("workflow.rejected", 1)] +internal sealed record WorkflowRejectedSideEffectData +{ + public required string Rejector { get; init; } + + public required int StepCount { get; init; } + + public required string State { get; init; } +} diff --git a/Examples/Core/WorkflowApprovals/Contracts/WorkflowStartedSideEffectData.cs b/Examples/Core/WorkflowApprovals/Contracts/WorkflowStartedSideEffectData.cs new file mode 100644 index 0000000..922255b --- /dev/null +++ b/Examples/Core/WorkflowApprovals/Contracts/WorkflowStartedSideEffectData.cs @@ -0,0 +1,13 @@ +using ModularityKit.Mutator.Abstractions.Effects; + +namespace WorkflowApprovals.Contracts; + +[SideEffectDataContract("workflow.started", 1)] +internal sealed record WorkflowStartedSideEffectData +{ + public required string Initiator { get; init; } + + public required int StepCount { get; init; } + + public required string WorkflowId { get; init; } +} diff --git a/Examples/Core/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs b/Examples/Core/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs index 0493b6a..7927059 100644 --- a/Examples/Core/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs +++ b/Examples/Core/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs @@ -4,6 +4,7 @@ using ModularityKit.Mutator.Abstractions.Effects; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Results; +using WorkflowApprovals.Contracts; using WorkflowApprovals.State; namespace WorkflowApprovals.Mutations; @@ -48,9 +49,9 @@ public override MutationResult Apply(ApprovalWorkflowStat SideEffect.Critical( type: "WorkflowRejected", description: "Workflow rejection requires manual follow-up", - data: new + data: new WorkflowRejectedSideEffectData { - Rejector, + Rejector = Rejector, StepCount = steps.Count, State = "Rejected" }) diff --git a/Examples/Core/WorkflowApprovals/Mutations/StartApprovalMutation.cs b/Examples/Core/WorkflowApprovals/Mutations/StartApprovalMutation.cs index 7cbf162..444fa4b 100644 --- a/Examples/Core/WorkflowApprovals/Mutations/StartApprovalMutation.cs +++ b/Examples/Core/WorkflowApprovals/Mutations/StartApprovalMutation.cs @@ -4,6 +4,7 @@ using ModularityKit.Mutator.Abstractions.Effects; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Results; +using WorkflowApprovals.Contracts; using WorkflowApprovals.State; namespace WorkflowApprovals.Mutations; @@ -54,9 +55,9 @@ public override MutationResult Apply(ApprovalWorkflowStat SideEffect.Create( type: "WorkflowStarted", description: "Approval workflow started and ready for first review", - data: new + data: new WorkflowStartedSideEffectData { - Initiator, + Initiator = Initiator, StepCount = steps.Count, WorkflowId = newState.WorkflowId }) diff --git a/Examples/Core/WorkflowApprovals/README.md b/Examples/Core/WorkflowApprovals/README.md index 1655d30..3910edd 100644 --- a/Examples/Core/WorkflowApprovals/README.md +++ b/Examples/Core/WorkflowApprovals/README.md @@ -67,7 +67,7 @@ The sample is intentionally sequential. It shows how stateful process can be adv - creates workflow steps from names - initializes a new workflow ID - emits change entry for the created step list -- emits a `WorkflowStarted` side effect through `SideEffect.Create(...)` +- emits a `WorkflowStarted` side effect through `SideEffect.Create(...)` with a typed contract payload ### Approve step @@ -85,7 +85,7 @@ The sample is intentionally sequential. It shows how stateful process can be adv - applies rejection to every step - records the actor who rejected the workflow - emits workflow level change -- emits a critical `WorkflowRejected` side effect through `SideEffect.Critical(...)` +- emits a critical `WorkflowRejected` side effect through `SideEffect.Critical(...)` with a typed contract payload ## Policies @@ -135,7 +135,8 @@ It shows: - a standard side effect created with `SideEffect.Create(...)` - a critical side effect created with `SideEffect.Critical(...)` -- how `Severity`, `RequiresAction`, and `Data` can be read from `MutationResult.SideEffects` +- how typed payload contracts are registered through `SideEffectDataContractRegistry` +- how `Severity`, `RequiresAction`, `DataContractType`, and typed `Data` can be read from `MutationResult.SideEffects` ## What to read first diff --git a/Examples/Core/WorkflowApprovals/Scenarios/SideEffectsScenario.cs b/Examples/Core/WorkflowApprovals/Scenarios/SideEffectsScenario.cs index 1e532ca..b1ba3ac 100644 --- a/Examples/Core/WorkflowApprovals/Scenarios/SideEffectsScenario.cs +++ b/Examples/Core/WorkflowApprovals/Scenarios/SideEffectsScenario.cs @@ -1,6 +1,8 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Effects; using ModularityKit.Mutator.Abstractions.Engine; +using System.Text.Json; +using WorkflowApprovals.Contracts; using WorkflowApprovals.Mutations; using WorkflowApprovals.State; @@ -12,6 +14,9 @@ internal static async Task Run(IMutationEngine engine) { Console.WriteLine("\n=== Side Effects Scenario ==="); + SideEffectDataContractRegistry.Register(); + SideEffectDataContractRegistry.Register(); + var state = new ApprovalWorkflowState(); var startContext = MutationContext.System("Start side effect demo", correlationId: "workflow-side-effects"); @@ -51,9 +56,25 @@ private static void PrintSideEffects(string operation, IReadOnlyList $" {effect.Type} | severity={effect.Severity} | requiresAction={effect.RequiresAction}"); Console.WriteLine($" {effect.Description}"); - if (effect.Data is not null) + var roundtrip = JsonSerializer.Deserialize(JsonSerializer.Serialize(effect)); + + if (roundtrip?.TryGetData(out var started) == true) + { + Console.WriteLine( + $" contract={roundtrip.DataContractType}@v{roundtrip.DataContractVersion} | initiator={started!.Initiator} | workflowId={started.WorkflowId}"); + continue; + } + + if (roundtrip?.TryGetData(out var rejected) == true) + { + Console.WriteLine( + $" contract={roundtrip.DataContractType}@v{roundtrip.DataContractVersion} | rejector={rejected!.Rejector} | state={rejected.State}"); + continue; + } + + if (roundtrip?.Data is not null) { - Console.WriteLine($" data={effect.Data}"); + Console.WriteLine($" data={roundtrip.Data}"); } } } diff --git a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs index 15e796e..dd4ac38 100644 --- a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs +++ b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs @@ -1,4 +1,5 @@ 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.Approval.Model; @@ -351,6 +352,16 @@ private static MutationRequest CreateExecutedRequest( CreatedAt = executedAt.AddMinutes(-15), UpdatedAt = executedAt, ExecutedAt = executedAt, + SideEffects = + [ + SideEffect.Critical( + type: "QuotaChangeRequiresReview", + description: "Executed quota change requires review", + data: new GovernanceExecutionSideEffectData + { + Ticket = "BILL-22" + }) + ], Metadata = new Dictionary { ["ticket"] = "BILL-22" @@ -380,4 +391,10 @@ private static MutationRequest CreateExecutedRequest( } ] }; + + [SideEffectDataContract("examples.governance.execution-side-effect", 1)] + private sealed record GovernanceExecutionSideEffectData + { + public required string Ticket { get; init; } + } } diff --git a/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs b/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs index b3a223d..2184901 100644 --- a/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs +++ b/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs @@ -55,6 +55,16 @@ public static async Task Run(IMutationRequestQueryStore queryStore) } })); + GovernanceQueriesSampleData.PrintSection("Requests With Actionable Execution Side Effects"); + GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery + { + SideEffects = new MutationRequestSideEffectFilter + { + DataContractTypes = new HashSet { "examples.governance.execution-side-effect" }, + RequiresAction = true + } + })); + GovernanceQueriesSampleData.PrintSection("Recent Approval Driven Requests"); GovernanceQueriesSampleData.PrintRequests(await queryStore.GetRecentApprovalsAsync(take: 3)); } diff --git a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs index 13a24f4..20487b4 100644 --- a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs +++ b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; 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; @@ -7,6 +8,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; +using ModularityKit.Mutator.Governance.Abstractions.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; @@ -50,6 +52,16 @@ public static async Task Run() ApproverIds = new HashSet { "security-lead" } })); + PrintSection("Requests With Actionable Side Effects"); + PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery + { + SideEffects = new MutationRequestSideEffectFilter + { + DataContractTypes = new HashSet { "examples.redis.execution-side-effect" }, + RequiresAction = true + } + })); + PrintSection("Recent Execution Outcomes"); PrintDecisions(await queryStore.GetRecentDecisionsAsync( MutationRequestDecisionQuery.RecentExecutionOutcomes(), @@ -152,6 +164,16 @@ private static MutationRequest CreateExecutedRequest( CreatedAt = executedAt.AddMinutes(-15), UpdatedAt = executedAt, ExecutedAt = executedAt, + SideEffects = + [ + SideEffect.Critical( + type: "ExecutedRequestRequiresFollowUp", + description: "Executed request still requires manual follow-up", + data: new RedisExecutionSideEffectData + { + Ticket = "BILL-12" + }) + ], Decisions = [ MutationRequestDecision.Lifecycle( @@ -178,6 +200,12 @@ private static MutationRequest CreateExecutedRequest( ] }; + [SideEffectDataContract("examples.redis.execution-side-effect", 1)] + private sealed record RedisExecutionSideEffectData + { + public required string Ticket { get; init; } + } + private static void PrintSection(string title) { Console.WriteLine(); 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 48bc73b..827a491 100644 --- a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs @@ -1,4 +1,5 @@ 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; @@ -61,7 +62,17 @@ 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) + 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 json = RedisMutationRequestSerializer.Serialize(request); @@ -75,9 +86,20 @@ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime() Assert.Equal(BlastRadiusScope.Module, roundtrip.Intent.EstimatedBlastRadius?.Scope); Assert.Equal("platform", roundtrip.Intent.Metadata["risk-owner"]); Assert.Equal("security", roundtrip.Metadata["team"]); + Assert.Single(roundtrip.SideEffects); + Assert.Equal("WorkflowRejected", roundtrip.SideEffects[0].Type); + Assert.Equal("redis.governance.side-effect", roundtrip.SideEffects[0].DataContractType); + Assert.True(roundtrip.SideEffects[0].TryGetData(out var sideEffectData)); + Assert.Equal("INC-42", sideEffectData!.Ticket); Assert.Single(roundtrip.Requirements); Assert.Single(roundtrip.ApprovalRequirements); Assert.Equal("security-lead", roundtrip.ApprovalRequirements[0].ApproverId); Assert.Equal(3, roundtrip.Decisions.Count); } + + [SideEffectDataContract("redis.governance.side-effect", 1)] + private sealed record RedisGovernanceSideEffectData + { + public required string Ticket { get; init; } + } } diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs index accecab..82b807f 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs @@ -3,6 +3,7 @@ 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; @@ -77,6 +78,9 @@ public async Task ExecuteApproved_executes_request_persists_resulting_version_an Assert.Equal("v11", result.Request.ResultingStateVersion); Assert.Equal("v11", result.Request.ExpectedStateVersion); Assert.NotNull(result.Request.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); @@ -255,7 +259,17 @@ public MutationResult Apply(RoleState state) return MutationResult.Success( newState, - ChangeSet.Single(StateChange.Modified("Role", state.Role, newState.Role))); + 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) @@ -267,4 +281,12 @@ public ValidationResult Validate(RoleState state) 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/Queries/MutationRequestQueryStoreTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs index c334e6b..c130006 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs @@ -1,4 +1,5 @@ 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; @@ -189,6 +190,78 @@ await store.Create(CreateGovernedRequest( 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() { @@ -474,7 +547,8 @@ private static MutationRequest CreateGovernedRequest( DateTimeOffset updatedAt, MutationRequestStatus status, PendingMutationReason? pendingReason, - IReadOnlyList decisions) + IReadOnlyList decisions, + IReadOnlyList? sideEffects = null) => new MutationRequest { RequestId = requestId, @@ -495,6 +569,13 @@ private static MutationRequest CreateGovernedRequest( CreatedAt = createdAt, UpdatedAt = updatedAt, Decisions = decisions, - Metadata = requestMetadata + 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.Tests/Effects/SideEffectTypedDataTests.cs b/Tests/ModularityKit.Mutator.Tests/Effects/SideEffectTypedDataTests.cs new file mode 100644 index 0000000..7479df9 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/Effects/SideEffectTypedDataTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using ModularityKit.Mutator.Abstractions.Effects; +using Xunit; + +namespace ModularityKit.Mutator.Tests.Effects; + +public sealed class SideEffectTypedDataTests +{ + [Fact] + public void Create_with_typed_payload_populates_contract_metadata() + { + var effect = SideEffect.Create( + type: "WorkflowStarted", + description: "Workflow started", + data: new WorkflowStartedSideEffectData + { + Initiator = "alice", + StepCount = 2, + WorkflowId = "wf-42" + }); + + Assert.Equal("workflow.started", effect.DataContractType); + Assert.Equal(1, effect.DataContractVersion); + Assert.True(effect.TryGetData(out var data)); + Assert.Equal("wf-42", data!.WorkflowId); + } + + [Fact] + public void Json_roundtrip_rehydrates_registered_typed_payload() + { + SideEffectDataContractRegistry.Register(); + + var effect = SideEffect.Create( + type: "WorkflowStarted", + description: "Workflow started", + data: new WorkflowStartedSideEffectData + { + Initiator = "alice", + StepCount = 2, + WorkflowId = "wf-42" + }); + + var roundtrip = JsonSerializer.Deserialize(JsonSerializer.Serialize(effect)); + + Assert.NotNull(roundtrip); + Assert.Equal("workflow.started", roundtrip!.DataContractType); + Assert.True(roundtrip.TryGetData(out var data)); + Assert.Equal("alice", data!.Initiator); + Assert.Equal(2, data.StepCount); + } + + [Fact] + public void Json_roundtrip_without_registration_preserves_contract_and_payload_shape() + { + var effect = new SideEffect + { + Type = "WorkflowStarted", + Description = "Workflow started", + Data = new WorkflowStartedSideEffectData + { + Initiator = "alice", + StepCount = 2, + WorkflowId = "wf-42" + }, + DataContractType = "workflow.started.unregistered", + DataContractVersion = 1 + }; + + var roundtrip = JsonSerializer.Deserialize(JsonSerializer.Serialize(effect)); + + Assert.NotNull(roundtrip); + Assert.Equal("workflow.started.unregistered", roundtrip!.DataContractType); + Assert.Equal(1, roundtrip.DataContractVersion); + + var payload = Assert.IsAssignableFrom>(roundtrip.Data); + 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/Runtime/MutationEnginePolicyTests.cs b/Tests/ModularityKit.Mutator.Tests/Runtime/MutationEnginePolicyTests.cs new file mode 100644 index 0000000..73563bc --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/Runtime/MutationEnginePolicyTests.cs @@ -0,0 +1,221 @@ +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 Xunit; + +namespace ModularityKit.Mutator.Tests.Runtime; + +public sealed class MutationEnginePolicyTests +{ + [Fact] + public async Task ExecuteAsync_supports_async_policy_evaluation() + { + var engine = CreateEngine(); + engine.RegisterPolicy(new AsyncBlockingPolicy()); + + var result = await engine.ExecuteAsync(new SampleMutation(), new SampleState("initial")); + + Assert.False(result.IsSuccess); + Assert.Single(result.PolicyDecisions); + Assert.Equal("AsyncBlocking", result.PolicyDecisions[0].PolicyName); + Assert.Equal("External compliance check rejected the mutation.", result.PolicyDecisions[0].Reason); + } + + [Fact] + public async Task ExecuteAsync_throws_policy_evaluation_timeout_exception_for_slow_policy() + { + var engine = CreateEngine(options => options.PolicyEvaluationTimeout = TimeSpan.FromMilliseconds(50)); + engine.RegisterPolicy(new SlowAsyncPolicy()); + + var exception = await Assert.ThrowsAsync(() => + engine.ExecuteAsync(new SampleMutation(), new SampleState("initial"))); + + Assert.Equal("SlowExternalCheck", exception.PolicyName); + } + + [Fact] + public async Task ExecuteAsync_wraps_policy_evaluation_failures() + { + var engine = CreateEngine(); + engine.RegisterPolicy(new FailingAsyncPolicy()); + + var exception = await Assert.ThrowsAsync(() => + engine.ExecuteAsync(new SampleMutation(), new SampleState("initial"))); + + Assert.Equal("FailingExternalCheck", exception.PolicyName); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task ExecuteAsync_preserves_caller_cancellation_during_policy_evaluation() + { + var engine = CreateEngine(); + engine.RegisterPolicy(new CancelAwareAsyncPolicy()); + using var cancellationSource = new CancellationTokenSource(millisecondsDelay: 50); + + await Assert.ThrowsAnyAsync(() => + engine.ExecuteAsync(new SampleMutation(), new SampleState("initial"), cancellationSource.Token)); + } + + [Fact] + public async Task ExecuteAsync_uses_sync_policy_path_without_async_override() + { + var engine = CreateEngine(); + engine.RegisterPolicy(new SyncAllowPolicy()); + + var result = await engine.ExecuteAsync(new SampleMutation(), new SampleState("initial")); + + Assert.True(result.IsSuccess); + Assert.Equal("updated", result.NewState!.Value); + } + + [Fact] + public async Task ExecuteAsync_allows_sync_and_async_policies_to_coexist_without_ambiguous_ordering() + { + var engine = 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")); + + 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/src/Abstractions/Effects/SideEffect.cs b/src/Abstractions/Effects/SideEffect.cs index 9025814..5b511a9 100644 --- a/src/Abstractions/Effects/SideEffect.cs +++ b/src/Abstractions/Effects/SideEffect.cs @@ -1,9 +1,12 @@ +using System.Text.Json.Serialization; + namespace ModularityKit.Mutator.Abstractions.Effects; /// /// Represents a side effect produced by a mutation. /// Side effects capture additional consequences that are not part of the primary state change. /// +[JsonConverter(typeof(SideEffectJsonConverter))] public sealed class SideEffect { /// @@ -28,6 +31,16 @@ public sealed class SideEffect /// public object? Data { get; init; } + /// + /// Stable contract identifier for typed side effect payloads. + /// + public string? DataContractType { get; init; } + + /// + /// Version number for typed side effect payloads. + /// + public int? DataContractVersion { get; init; } + /// /// Timestamp when the side effect occurred. /// @@ -43,7 +56,10 @@ public sealed class SideEffect /// /// The type of the side effect. /// Human-readable description. - /// Optional associated data. + /// + /// Optional associated data. When the payload type declares , + /// the side effect contract metadata is populated automatically. + /// /// Severity level. /// /// Indicates whether the side effect requires explicit follow-up. Critical severity always implies action. @@ -56,22 +72,47 @@ public static SideEffect Create( SideEffectSeverity severity = SideEffectSeverity.Info, bool requiresAction = false, DateTimeOffset? timestamp = null) - => new() - { - Type = type, - Description = description, - Data = data, - Severity = severity, - RequiresAction = requiresAction || severity == SideEffectSeverity.Critical, - Timestamp = timestamp ?? DateTimeOffset.UtcNow - }; + => CreateCore( + type, + description, + data, + severity, + requiresAction, + timestamp); + + /// + /// Creates a new with a typed payload contract. + /// + /// The payload type. + /// The type of the side effect. + /// Human-readable description. + /// Typed associated payload. + /// Severity level. + /// + /// Indicates whether the side effect requires explicit follow-up. Critical severity always implies action. + /// + /// Optional timestamp override. Defaults to current UTC time. + public static SideEffect Create( + string type, + string description, + TData data, + SideEffectSeverity severity = SideEffectSeverity.Info, + bool requiresAction = false, + DateTimeOffset? timestamp = null) + { + ArgumentNullException.ThrowIfNull(data); + return CreateCore(type, description, data, severity, requiresAction, timestamp); + } /// /// Creates a new critical instance. /// /// The type of the side effect. /// Human-readable description. - /// Optional associated data. + /// + /// Optional associated data. When the payload type declares , + /// the side effect contract metadata is populated automatically. + /// /// Optional timestamp override. Defaults to current UTC time. public static SideEffect Critical( string type, @@ -85,4 +126,83 @@ public static SideEffect Critical( SideEffectSeverity.Critical, requiresAction: true, timestamp: timestamp); + + /// + /// Creates a new critical instance with a typed payload contract. + /// + /// The payload type. + /// The type of the side effect. + /// Human-readable description. + /// Typed associated payload. + /// Optional timestamp override. Defaults to current UTC time. + public static SideEffect Critical( + string type, + string description, + TData data, + DateTimeOffset? timestamp = null) + => Create( + type, + description, + data, + SideEffectSeverity.Critical, + requiresAction: true, + timestamp: timestamp); + + /// + /// Attempts to read the side effect payload as a typed contract. + /// + /// The expected payload type. + /// The typed payload when available. + /// when the payload is available as . + public bool TryGetData(out TData? data) + { + if (Data is TData typed) + { + data = typed; + return true; + } + + data = default; + return false; + } + + private static SideEffect CreateCore( + string type, + string description, + object? data, + SideEffectSeverity severity, + bool requiresAction, + DateTimeOffset? timestamp) + { + var (contractType, contractVersion) = ResolveContract(data); + + return new SideEffect + { + Type = type, + Description = description, + Data = data, + Severity = severity, + RequiresAction = requiresAction || severity == SideEffectSeverity.Critical, + Timestamp = timestamp ?? DateTimeOffset.UtcNow, + DataContractType = contractType, + DataContractVersion = contractVersion + }; + } + + private static (string? ContractType, int? ContractVersion) ResolveContract(object? data) + { + if (data is null) + return (null, null); + + var dataType = data.GetType(); + var contract = dataType.GetCustomAttributes(typeof(SideEffectDataContractAttribute), inherit: false) + .OfType() + .SingleOrDefault(); + + if (contract is null) + return (null, null); + + SideEffectDataContractRegistry.Register(dataType); + return (contract.ContractType, contract.ContractVersion); + } } diff --git a/src/Abstractions/Effects/SideEffectDataContractAttribute.cs b/src/Abstractions/Effects/SideEffectDataContractAttribute.cs new file mode 100644 index 0000000..0bacb50 --- /dev/null +++ b/src/Abstractions/Effects/SideEffectDataContractAttribute.cs @@ -0,0 +1,22 @@ +namespace ModularityKit.Mutator.Abstractions.Effects; + +/// +/// Declares stable contract identifier for typed side effect payloads. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class SideEffectDataContractAttribute(string contractType, int contractVersion = 1) : Attribute +{ + /// + /// Stable contract identifier for the payload. + /// + public string ContractType { get; } = string.IsNullOrWhiteSpace(contractType) + ? throw new ArgumentException("Contract type is required.", nameof(contractType)) + : contractType; + + /// + /// Version number for the payload contract. + /// + public int ContractVersion { get; } = contractVersion > 0 + ? contractVersion + : throw new ArgumentOutOfRangeException(nameof(contractVersion), "Contract version must be greater than zero."); +} diff --git a/src/Abstractions/Effects/SideEffectDataContractRegistry.cs b/src/Abstractions/Effects/SideEffectDataContractRegistry.cs new file mode 100644 index 0000000..cadf294 --- /dev/null +++ b/src/Abstractions/Effects/SideEffectDataContractRegistry.cs @@ -0,0 +1,74 @@ +using System.Collections.Concurrent; + +namespace ModularityKit.Mutator.Abstractions.Effects; + +/// +/// Stores registrations that map side effect payload contracts to CLR types. +/// Registered types allow serializers and integration layers to rehydrate typed payloads +/// from stable contract identifiers instead of inferring payload shape at runtime. +/// +public static class SideEffectDataContractRegistry +{ + private static readonly ConcurrentDictionary<(string ContractType, int ContractVersion), Type> TypesByContract = new(); + + /// + /// Registers typed side effect payload contract. + /// + /// The payload type to register. + public static void Register() + => Register(typeof(TData)); + + /// + /// Registers typed side effect payload contract. + /// + /// The payload type to register. + public static void Register(Type dataType) + { + ArgumentNullException.ThrowIfNull(dataType); + + var contract = GetRequiredContract(dataType); + TypesByContract[(contract.ContractType, contract.ContractVersion)] = dataType; + } + + /// + /// Attempts to resolve payload CLR type from side effect data contract. + /// + /// The stable contract identifier. + /// The contract version. + /// The resolved CLR type when present. + /// when the contract is registered; otherwise . + public static bool TryResolve(string contractType, int contractVersion, out Type? dataType) + { + if (string.IsNullOrWhiteSpace(contractType) || contractVersion <= 0) + { + dataType = null; + return false; + } + + return TypesByContract.TryGetValue((contractType, contractVersion), out dataType); + } + + /// + /// Reads the declared side effect data contract for CLR type. + /// + /// The payload type. + /// The declared side effect data contract. + public static SideEffectDataContractAttribute GetRequiredContract() + => GetRequiredContract(typeof(TData)); + + /// + /// Reads the declared side effect data contract for CLR type. + /// + /// The payload type. + /// The declared side effect data contract. + public static SideEffectDataContractAttribute GetRequiredContract(Type dataType) + { + ArgumentNullException.ThrowIfNull(dataType); + + return dataType.GetCustomAttributes(typeof(SideEffectDataContractAttribute), inherit: false) + .OfType() + .SingleOrDefault() + ?? throw new InvalidOperationException( + $"Typed side effect payload '{dataType.FullName}' must declare {nameof(SideEffectDataContractAttribute)}."); + } +} diff --git a/src/Abstractions/Effects/SideEffectJsonConverter.cs b/src/Abstractions/Effects/SideEffectJsonConverter.cs new file mode 100644 index 0000000..0d2bcc5 --- /dev/null +++ b/src/Abstractions/Effects/SideEffectJsonConverter.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModularityKit.Mutator.Abstractions.Effects; + +/// +/// Serializes side effects while preserving typed payload contracts when registered. +/// When a payload contract is unknown at read time, the converter falls back to inferred +/// dictionary and list materialization so side effect meaning is not lost. +/// +public sealed class SideEffectJsonConverter : JsonConverter +{ + /// + public override SideEffect Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + + var contractType = TryReadString(root, nameof(SideEffect.DataContractType)); + var contractVersion = TryReadInt(root, nameof(SideEffect.DataContractVersion)); + + return new SideEffect + { + Type = root.GetProperty(nameof(SideEffect.Type)).GetString() ?? string.Empty, + Description = root.GetProperty(nameof(SideEffect.Description)).GetString() ?? string.Empty, + Severity = root.GetProperty(nameof(SideEffect.Severity)).Deserialize(options), + Data = TryReadData(root, contractType, contractVersion, options), + Timestamp = root.GetProperty(nameof(SideEffect.Timestamp)).GetDateTimeOffset(), + RequiresAction = root.GetProperty(nameof(SideEffect.RequiresAction)).GetBoolean(), + DataContractType = contractType, + DataContractVersion = contractVersion + }; + } + + /// + public override void Write(Utf8JsonWriter writer, SideEffect value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString(nameof(SideEffect.Type), value.Type); + writer.WriteString(nameof(SideEffect.Description), value.Description); + writer.WritePropertyName(nameof(SideEffect.Severity)); + JsonSerializer.Serialize(writer, value.Severity, options); + writer.WritePropertyName(nameof(SideEffect.Data)); + if (value.Data is null) + { + writer.WriteNullValue(); + } + else + { + JsonSerializer.Serialize(writer, value.Data, value.Data.GetType(), options); + } + + writer.WriteString(nameof(SideEffect.Timestamp), value.Timestamp); + writer.WriteBoolean(nameof(SideEffect.RequiresAction), value.RequiresAction); + + if (value.DataContractType is null) + writer.WriteNull(nameof(SideEffect.DataContractType)); + else + writer.WriteString(nameof(SideEffect.DataContractType), value.DataContractType); + + if (value.DataContractVersion is null) + writer.WriteNull(nameof(SideEffect.DataContractVersion)); + else + writer.WriteNumber(nameof(SideEffect.DataContractVersion), value.DataContractVersion.Value); + + writer.WriteEndObject(); + } + + /// + /// Reads the side effect payload, preferring a registered typed contract when available. + /// + /// The serialized side effect object. + /// The optional payload contract identifier. + /// The optional payload contract version. + /// The serializer options. + /// The typed payload when registered; otherwise an inferred object graph. + private static object? TryReadData( + JsonElement root, + string? contractType, + int? contractVersion, + JsonSerializerOptions options) + { + if (!root.TryGetProperty(nameof(SideEffect.Data), out var dataElement)) + return null; + + if (dataElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + return null; + + if (contractType is not null && + contractVersion is not null && + SideEffectDataContractRegistry.TryResolve(contractType, contractVersion.Value, out var dataType)) + { + return JsonSerializer.Deserialize(dataElement.GetRawText(), dataType!, options); + } + + return ReadInferredValue(dataElement); + } + + /// + /// Reads an optional string property from the serialized side effect object. + /// + /// The serialized side effect object. + /// The property name to read. + /// The string value when present; otherwise . + private static string? TryReadString(JsonElement root, string propertyName) + => root.TryGetProperty(propertyName, out var property) && property.ValueKind != JsonValueKind.Null + ? property.GetString() + : null; + + /// + /// Reads an optional integer property from the serialized side effect object. + /// + /// The serialized side effect object. + /// The property name to read. + /// The integer value when present; otherwise . + private static int? TryReadInt(JsonElement root, string propertyName) + => root.TryGetProperty(propertyName, out var property) && property.ValueKind != JsonValueKind.Null + ? property.GetInt32() + : null; + + /// + /// Materializes JSON into a best-effort inferred CLR object graph. + /// + /// The JSON value to materialize. + /// A dictionary, list, scalar, or depending on the JSON token. + private static object? ReadInferredValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Object => value.EnumerateObject() + .ToDictionary(property => property.Name, property => ReadInferredValue(property.Value), StringComparer.Ordinal), + JsonValueKind.Array => value.EnumerateArray().Select(ReadInferredValue).ToList(), + JsonValueKind.String when value.TryGetDateTimeOffset(out var dateTimeOffset) => dateTimeOffset, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number when value.TryGetInt64(out var int64) => int64, + JsonValueKind.Number => value.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null or JsonValueKind.Undefined => null, + _ => value.GetRawText() + }; +} diff --git a/src/Abstractions/Exceptions/PolicyEvaluationException.cs b/src/Abstractions/Exceptions/PolicyEvaluationException.cs new file mode 100644 index 0000000..6d7b76a --- /dev/null +++ b/src/Abstractions/Exceptions/PolicyEvaluationException.cs @@ -0,0 +1,20 @@ +namespace ModularityKit.Mutator.Abstractions.Exceptions; + +/// +/// Exception thrown when a policy evaluation fails before producing a decision. +/// +/// +/// Initializes a new instance of . +/// +/// The policy name. +/// Human-readable description of the failure. +/// The underlying failure. +public class PolicyEvaluationException(string policyName, string message, Exception? innerException = null) + : MutationException(message, innerException ?? new Exception(message)) +{ + + /// + /// The name of the policy that failed. + /// + public string PolicyName { get; } = policyName; +} diff --git a/src/Abstractions/Exceptions/PolicyEvaluationTimeoutException.cs b/src/Abstractions/Exceptions/PolicyEvaluationTimeoutException.cs new file mode 100644 index 0000000..ef30a37 --- /dev/null +++ b/src/Abstractions/Exceptions/PolicyEvaluationTimeoutException.cs @@ -0,0 +1,19 @@ +namespace ModularityKit.Mutator.Abstractions.Exceptions; + +/// +/// Exception thrown when policy evaluation exceeds the configured timeout. +/// +/// +/// Initializes new instance of . +/// +/// The policy name. +/// The configured timeout. +public sealed class PolicyEvaluationTimeoutException(string policyName, TimeSpan timeout) : PolicyEvaluationException( + policyName, + $"Policy '{policyName}' evaluation timed out after {timeout.TotalSeconds:0.###}s.") +{ + /// + /// The configured timeout for policy evaluation. + /// + public TimeSpan Timeout { get; } = timeout; +} diff --git a/src/Abstractions/MutationEngineOptions.cs b/src/Abstractions/MutationEngineOptions.cs index f2ff384..18fc6af 100644 --- a/src/Abstractions/MutationEngineOptions.cs +++ b/src/Abstractions/MutationEngineOptions.cs @@ -36,6 +36,16 @@ public sealed class MutationEngineOptions /// public TimeSpan? ExecutionTimeout { get; set; } + /// + /// The maximum allowed evaluation time for a single policy. + /// + /// + /// When specified, each registered policy evaluation gets its own timeout window. + /// This is primarily intended for async policies that call external identity, ticketing, + /// quota, or compliance systems. + /// + public TimeSpan? PolicyEvaluationTimeout { get; set; } + /// /// Indicates whether batch execution should stop after the first failure. /// @@ -49,7 +59,7 @@ public sealed class MutationEngineOptions /// Enables collection of detailed execution metrics. /// /// - /// Detailed metrics provide deep observability but may have a measurable + /// Detailed metrics provide deep observability but may have measurable /// performance impact in high-throughput scenarios. /// public bool EnableDetailedMetrics { get; set; } = false; @@ -82,7 +92,7 @@ public sealed class MutationEngineOptions }; /// - /// Performance-oriented configuration minimizing overhead. + /// Performance oriented configuration minimizing overhead. /// /// /// Intended for trusted environments where validation and detailed metrics diff --git a/src/Abstractions/Policies/IMutationPolicy.cs b/src/Abstractions/Policies/IMutationPolicy.cs index b090bd4..a921095 100644 --- a/src/Abstractions/Policies/IMutationPolicy.cs +++ b/src/Abstractions/Policies/IMutationPolicy.cs @@ -3,8 +3,8 @@ namespace ModularityKit.Mutator.Abstractions.Policies; /// -/// Represents a policy that decides whether a mutation can be applied. -/// Policies are a FIRST-CLASS governance mechanism in the mutation framework. +/// Represents policy that decides whether a mutation can be applied. +/// Policies are FIRST CLASS governance mechanism in the mutation framework. /// /// Type of the state the mutation operates on. public interface IMutationPolicy @@ -31,5 +31,19 @@ public interface IMutationPolicy /// The mutation to evaluate. /// The current state before applying the mutation. /// A representing the result of the evaluation. - PolicyDecision Evaluate(IMutation mutation, TState state); + PolicyDecision Evaluate(IMutation mutation, TState state) + => throw new NotSupportedException("This policy does not implement synchronous evaluation."); + + /// + /// Evaluates whether the given mutation is allowed on the current state asynchronously. + /// + /// The mutation to evaluate. + /// The current state before applying the mutation. + /// Token used to cancel the policy evaluation. + /// A representing the result of the evaluation. + Task EvaluateAsync( + IMutation mutation, + TState state, + CancellationToken cancellationToken = default) + => Task.FromResult(Evaluate(mutation, state)); } diff --git a/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQueryEvaluator.cs index 72c7040..511ab05 100644 --- a/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQueryEvaluator.cs +++ b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQueryEvaluator.cs @@ -1,4 +1,5 @@ using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation; using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; diff --git a/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQueryEvaluator.cs index 6bcb55a..bf23993 100644 --- a/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQueryEvaluator.cs +++ b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQueryEvaluator.cs @@ -1,4 +1,5 @@ using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation; using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestActorFilterMatcher.cs b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestActorFilterMatcher.cs new file mode 100644 index 0000000..89eade7 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestActorFilterMatcher.cs @@ -0,0 +1,28 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation.Matchers; + +/// +/// Evaluates actor-oriented request filters. +/// +internal static class MutationRequestActorFilterMatcher +{ + /// + /// Determines whether a request matches the supplied actor filter. + /// + /// The request to evaluate. + /// The actor filter. + /// when the request matches; otherwise . + public static bool Matches(MutationRequest request, MutationRequestActorFilter filter) + => MatchesActorId(request, filter) && + MatchesActorName(request, filter); + + private static bool MatchesActorId(MutationRequest request, MutationRequestActorFilter filter) + => filter.ActorIds.Count == 0 || + (request.Context.ActorId is not null && filter.ActorIds.Contains(request.Context.ActorId)); + + private static bool MatchesActorName(MutationRequest request, MutationRequestActorFilter filter) + => filter.ActorNames.Count == 0 || + (request.Context.ActorName is not null && filter.ActorNames.Contains(request.Context.ActorName)); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestIntentFilterMatcher.cs b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestIntentFilterMatcher.cs new file mode 100644 index 0000000..ef8cf64 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestIntentFilterMatcher.cs @@ -0,0 +1,57 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation.Matchers; + +/// +/// Evaluates intent-oriented request filters. +/// +internal static class MutationRequestIntentFilterMatcher +{ + /// + /// Determines whether a request matches the supplied intent filter. + /// + /// The request to evaluate. + /// The intent filter. + /// when the request matches; otherwise . + public static bool Matches(MutationRequest request, MutationRequestIntentFilter filter) + => MatchesCategory(request, filter) && + MatchesTags(request, filter) && + MatchesMetadata(request, filter) && + MatchesBlastRadius(request, filter); + + private static bool MatchesCategory(MutationRequest request, MutationRequestIntentFilter filter) + => filter.Categories.Count == 0 || filter.Categories.Contains(request.Intent.Category); + + private static bool MatchesTags(MutationRequest request, MutationRequestIntentFilter filter) + { + if (filter.Tags.Count == 0) + return true; + + var requestTags = request.Intent.Tags; + return filter.TagMatchMode == MutationRequestTagMatchMode.All + ? filter.Tags.All(requestTags.Contains) + : filter.Tags.Any(requestTags.Contains); + } + + private static bool MatchesMetadata(MutationRequest request, MutationRequestIntentFilter filter) + => filter.Metadata.Count == 0 || MutationRequestMetadataMatcher.Matches(request.Intent.Metadata, filter.Metadata); + + private static bool MatchesBlastRadius(MutationRequest request, MutationRequestIntentFilter filter) + { + if (!filter.MinimumBlastRadiusScope.HasValue && !filter.MaximumBlastRadiusScope.HasValue) + return true; + + var scope = request.Intent.EstimatedBlastRadius?.Scope; + if (scope is null) + return false; + + if (filter.MinimumBlastRadiusScope.HasValue && scope.Value < filter.MinimumBlastRadiusScope.Value) + return false; + + if (filter.MaximumBlastRadiusScope.HasValue && scope.Value > filter.MaximumBlastRadiusScope.Value) + return false; + + return true; + } +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestLifecycleFilterMatcher.cs b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestLifecycleFilterMatcher.cs new file mode 100644 index 0000000..1d3ff8f --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestLifecycleFilterMatcher.cs @@ -0,0 +1,32 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation.Matchers; + +/// +/// Evaluates lifecycle-oriented request filters. +/// +internal static class MutationRequestLifecycleFilterMatcher +{ + /// + /// Determines whether a request matches the supplied lifecycle filter. + /// + /// The request to evaluate. + /// The lifecycle filter. + /// when the request matches; otherwise . + public static bool Matches(MutationRequest request, MutationRequestLifecycleFilter filter) + => MatchesStatus(request, filter) && + MatchesPendingReason(request, filter) && + MatchesDecisionCategories(request, filter); + + private static bool MatchesStatus(MutationRequest request, MutationRequestLifecycleFilter filter) + => filter.Statuses.Count == 0 || filter.Statuses.Contains(request.Status); + + private static bool MatchesPendingReason(MutationRequest request, MutationRequestLifecycleFilter filter) + => filter.PendingReasons.Count == 0 || + (request.PendingReason is not null && filter.PendingReasons.Contains(request.PendingReason.Value)); + + private static bool MatchesDecisionCategories(MutationRequest request, MutationRequestLifecycleFilter filter) + => filter.DecisionCategories.Count == 0 || + request.Decisions.Any(decision => filter.DecisionCategories.Contains(decision.Type.Category)); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestMetadataMatcher.cs b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestMetadataMatcher.cs new file mode 100644 index 0000000..f4693dc --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestMetadataMatcher.cs @@ -0,0 +1,29 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation.Matchers; + +/// +/// Evaluates exact metadata matches between governed requests and query filters. +/// +internal static class MutationRequestMetadataMatcher +{ + /// + /// Determines whether request metadata contains all queried key/value pairs. + /// + /// The request or intent metadata to inspect. + /// The queried key/value pairs. + /// when all queried entries match exactly; otherwise . + public static bool Matches( + IReadOnlyDictionary requestMetadata, + IReadOnlyDictionary queryMetadata) + { + foreach (var pair in queryMetadata) + { + if (!requestMetadata.TryGetValue(pair.Key, out var value)) + return false; + + if (!Equals(value, pair.Value)) + return false; + } + + return true; + } +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestScopeFilterMatcher.cs b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestScopeFilterMatcher.cs new file mode 100644 index 0000000..5bc173d --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestScopeFilterMatcher.cs @@ -0,0 +1,34 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation.Matchers; + +/// +/// Evaluates identifier and type-oriented request filters. +/// +internal static class MutationRequestScopeFilterMatcher +{ + /// + /// Determines whether a request matches the supplied scope filter. + /// + /// The request to evaluate. + /// The scope filter. + /// when the request matches; otherwise . + public static bool Matches(MutationRequest request, MutationRequestScopeFilter filter) + => MatchesRequestId(request, filter) && + MatchesStateId(request, filter) && + MatchesStateType(request, filter) && + MatchesMutationType(request, filter); + + private static bool MatchesRequestId(MutationRequest request, MutationRequestScopeFilter filter) + => filter.RequestIds.Count == 0 || filter.RequestIds.Contains(request.RequestId); + + private static bool MatchesStateId(MutationRequest request, MutationRequestScopeFilter filter) + => filter.StateIds.Count == 0 || filter.StateIds.Contains(request.StateId); + + private static bool MatchesStateType(MutationRequest request, MutationRequestScopeFilter filter) + => filter.StateTypes.Count == 0 || filter.StateTypes.Contains(request.StateType); + + private static bool MatchesMutationType(MutationRequest request, MutationRequestScopeFilter filter) + => filter.MutationTypes.Count == 0 || filter.MutationTypes.Contains(request.MutationType); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestSideEffectFilterMatcher.cs b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestSideEffectFilterMatcher.cs new file mode 100644 index 0000000..e92e890 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestSideEffectFilterMatcher.cs @@ -0,0 +1,34 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation.Matchers; + +/// +/// Evaluates persisted side effect filters on governed requests. +/// +internal static class MutationRequestSideEffectFilterMatcher +{ + /// + /// Determines whether a request matches the supplied side effect filter. + /// + /// The request to evaluate. + /// The side effect filter. + /// when the request matches; otherwise . + public static bool Matches(MutationRequest request, MutationRequestSideEffectFilter filter) + { + if (filter.Types.Count == 0 && + filter.DataContractTypes.Count == 0 && + filter.Severities.Count == 0 && + !filter.RequiresAction.HasValue) + { + return true; + } + + return request.SideEffects.Any(effect => + (filter.Types.Count == 0 || filter.Types.Contains(effect.Type)) && + (filter.DataContractTypes.Count == 0 || + (effect.DataContractType is not null && filter.DataContractTypes.Contains(effect.DataContractType))) && + (filter.Severities.Count == 0 || filter.Severities.Contains(effect.Severity)) && + (!filter.RequiresAction.HasValue || effect.RequiresAction == filter.RequiresAction.Value)); + } +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestTimeRangeFilterMatcher.cs b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestTimeRangeFilterMatcher.cs new file mode 100644 index 0000000..2a6675e --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/Matchers/MutationRequestTimeRangeFilterMatcher.cs @@ -0,0 +1,42 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation.Matchers; + +/// +/// Evaluates creation and update time filters on governed requests. +/// +internal static class MutationRequestTimeRangeFilterMatcher +{ + /// + /// Determines whether a request matches the supplied time range filter. + /// + /// The request to evaluate. + /// The time range filter. + /// when the request matches; otherwise . + public static bool Matches(MutationRequest request, MutationRequestTimeRangeFilter filter) + => MatchesCreatedAt(request, filter) && + MatchesUpdatedAt(request, filter); + + private static bool MatchesCreatedAt(MutationRequest request, MutationRequestTimeRangeFilter filter) + { + if (filter.CreatedFrom.HasValue && request.CreatedAt < filter.CreatedFrom.Value) + return false; + + if (filter.CreatedTo.HasValue && request.CreatedAt > filter.CreatedTo.Value) + return false; + + return true; + } + + private static bool MatchesUpdatedAt(MutationRequest request, MutationRequestTimeRangeFilter filter) + { + if (filter.UpdatedFrom.HasValue && request.UpdatedAt < filter.UpdatedFrom.Value) + return false; + + if (filter.UpdatedTo.HasValue && request.UpdatedAt > filter.UpdatedTo.Value) + return false; + + return true; + } +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/MutationRequestQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/MutationRequestQueryEvaluator.cs new file mode 100644 index 0000000..e9ae939 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Evaluation/MutationRequestQueryEvaluator.cs @@ -0,0 +1,66 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation.Matchers; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation; + +/// +/// Evaluates query criteria against governed mutation requests. +/// +public static class MutationRequestQueryEvaluator +{ + /// + /// Determines whether a governed request matches the supplied query. + /// + /// The request to evaluate. + /// The query criteria. + /// when the request matches; otherwise . + public static bool Matches(MutationRequest request, MutationRequestQuery query) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(query); + + return MutationRequestScopeFilterMatcher.Matches(request, query.Scope) && + MutationRequestActorFilterMatcher.Matches(request, query.Actor) && + MutationRequestIntentFilterMatcher.Matches(request, query.Intent) && + MatchesMetadata(request, query) && + MutationRequestLifecycleFilterMatcher.Matches(request, query.Lifecycle) && + MutationRequestTimeRangeFilterMatcher.Matches(request, query.TimeRange) && + MutationRequestSideEffectFilterMatcher.Matches(request, query.SideEffects); + } + + /// + /// Determines whether a request has approval-oriented activity. + /// + /// The request to inspect. + /// when approval requirements or approval decisions are present. + public static bool HasApprovalActivity(MutationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + return request.ApprovalRequirements.Count > 0 || + request.Decisions.Any(decision => decision.Type.Category == MutationRequestDecisionCategory.Approval); + } + + /// + /// Gets the most recent approval activity timestamp for ordering purposes. + /// + /// The request to inspect. + /// The latest approval decision timestamp, or the request update time when no terminal approval decision exists. + public static DateTimeOffset GetRecentApprovalTimestamp(MutationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var approvalDecision = request.Decisions + .Where(decision => decision.Type.Category == MutationRequestDecisionCategory.Approval && + decision.Type.Code != MutationRequestApprovalDecisionType.Requested.ToString()) + .OrderByDescending(decision => decision.Timestamp) + .FirstOrDefault(); + + return approvalDecision?.Timestamp ?? request.UpdatedAt; + } + + private static bool MatchesMetadata(MutationRequest request, MutationRequestQuery query) + => query.Metadata.Values.Count == 0 || + MutationRequestMetadataMatcher.Matches(request.Metadata, query.Metadata.Values); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestSideEffectFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestSideEffectFilter.cs new file mode 100644 index 0000000..4b64adc --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestSideEffectFilter.cs @@ -0,0 +1,29 @@ +using ModularityKit.Mutator.Abstractions.Effects; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Filters governed requests by persisted side effect characteristics. +/// +public sealed record MutationRequestSideEffectFilter +{ + /// + /// Side effect types that may appear on the request. + /// + public IReadOnlySet Types { get; init; } = new HashSet(); + + /// + /// Stable side effect payload contract identifiers that may appear on the request. + /// + public IReadOnlySet DataContractTypes { get; init; } = new HashSet(); + + /// + /// Side effect severity levels that may appear on the request. + /// + public IReadOnlySet Severities { get; init; } = new HashSet(); + + /// + /// Optional flag indicating whether the request must contain a side effect that requires action. + /// + public bool? RequiresAction { get; init; } +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs index bf91f98..433bbd5 100644 --- a/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs +++ b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs @@ -36,4 +36,9 @@ public sealed record MutationRequestQuery /// Time-window filters for request creation and update activity. /// public MutationRequestTimeRangeFilter TimeRange { get; init; } = new(); + + /// + /// Filters for persisted side effects captured on governed execution requests. + /// + public MutationRequestSideEffectFilter SideEffects { get; init; } = new(); } diff --git a/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueryEvaluator.cs deleted file mode 100644 index 177f51d..0000000 --- a/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueryEvaluator.cs +++ /dev/null @@ -1,177 +0,0 @@ -using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; - -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; - -/// -/// Evaluates query criteria against governed mutation requests. -/// -public static class MutationRequestQueryEvaluator -{ - public static bool Matches(MutationRequest request, MutationRequestQuery query) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(query); - - return MatchesScope(request, query.Scope) && - MatchesActor(request, query.Actor) && - MatchesIntent(request, query.Intent) && - MatchesMetadata(request, query.Metadata) && - MatchesLifecycle(request, query.Lifecycle) && - MatchesTimeRange(request, query.TimeRange); - } - - public static bool HasApprovalActivity(MutationRequest request) - { - ArgumentNullException.ThrowIfNull(request); - - return request.ApprovalRequirements.Count > 0 || - request.Decisions.Any(decision => decision.Type.Category == MutationRequestDecisionCategory.Approval); - } - - public static DateTimeOffset GetRecentApprovalTimestamp(MutationRequest request) - { - ArgumentNullException.ThrowIfNull(request); - - var approvalDecision = request.Decisions - .Where(decision => decision.Type.Category == MutationRequestDecisionCategory.Approval && - decision.Type.Code != MutationRequestApprovalDecisionType.Requested.ToString()) - .OrderByDescending(decision => decision.Timestamp) - .FirstOrDefault(); - - return approvalDecision?.Timestamp ?? request.UpdatedAt; - } - - private static bool MatchesScope(MutationRequest request, MutationRequestScopeFilter filter) - => MatchesRequestId(request, filter) && - MatchesStateId(request, filter) && - MatchesStateType(request, filter) && - MatchesMutationType(request, filter); - - private static bool MatchesActor(MutationRequest request, MutationRequestActorFilter filter) - => MatchesActorId(request, filter) && - MatchesActorName(request, filter); - - private static bool MatchesIntent(MutationRequest request, MutationRequestIntentFilter filter) - => MatchesCategory(request, filter) && - MatchesTags(request, filter) && - MatchesIntentMetadata(request, filter) && - MatchesBlastRadius(request, filter); - - private static bool MatchesMetadata(MutationRequest request, MutationRequestMetadataFilter filter) - => filter.Values.Count == 0 || MatchesMetadata(request.Metadata, filter.Values); - - private static bool MatchesLifecycle(MutationRequest request, MutationRequestLifecycleFilter filter) - => MatchesStatus(request, filter) && - MatchesPendingReason(request, filter) && - MatchesDecisionCategories(request, filter); - - private static bool MatchesTimeRange(MutationRequest request, MutationRequestTimeRangeFilter filter) - => MatchesCreatedAt(request, filter) && - MatchesUpdatedAt(request, filter); - - private static bool MatchesIntentMetadata(MutationRequest request, MutationRequestIntentFilter filter) - => filter.Metadata.Count == 0 || MatchesMetadata(request.Intent.Metadata, filter.Metadata); - - private static bool MatchesRequestId(MutationRequest request, MutationRequestScopeFilter filter) - => filter.RequestIds.Count == 0 || filter.RequestIds.Contains(request.RequestId); - - private static bool MatchesStateId(MutationRequest request, MutationRequestScopeFilter filter) - => filter.StateIds.Count == 0 || filter.StateIds.Contains(request.StateId); - - private static bool MatchesStateType(MutationRequest request, MutationRequestScopeFilter filter) - => filter.StateTypes.Count == 0 || filter.StateTypes.Contains(request.StateType); - - private static bool MatchesMutationType(MutationRequest request, MutationRequestScopeFilter filter) - => filter.MutationTypes.Count == 0 || filter.MutationTypes.Contains(request.MutationType); - - private static bool MatchesActorId(MutationRequest request, MutationRequestActorFilter filter) - => filter.ActorIds.Count == 0 || - (request.Context.ActorId is not null && filter.ActorIds.Contains(request.Context.ActorId)); - - private static bool MatchesActorName(MutationRequest request, MutationRequestActorFilter filter) - => filter.ActorNames.Count == 0 || - (request.Context.ActorName is not null && filter.ActorNames.Contains(request.Context.ActorName)); - - private static bool MatchesCategory(MutationRequest request, MutationRequestIntentFilter filter) - => filter.Categories.Count == 0 || filter.Categories.Contains(request.Intent.Category); - - private static bool MatchesStatus(MutationRequest request, MutationRequestLifecycleFilter filter) - => filter.Statuses.Count == 0 || filter.Statuses.Contains(request.Status); - - private static bool MatchesPendingReason(MutationRequest request, MutationRequestLifecycleFilter filter) - => filter.PendingReasons.Count == 0 || - (request.PendingReason is not null && filter.PendingReasons.Contains(request.PendingReason.Value)); - - private static bool MatchesTags(MutationRequest request, MutationRequestIntentFilter filter) - { - if (filter.Tags.Count == 0) - return true; - - var requestTags = request.Intent.Tags; - return filter.TagMatchMode == MutationRequestTagMatchMode.All - ? filter.Tags.All(requestTags.Contains) - : filter.Tags.Any(requestTags.Contains); - } - - private static bool MatchesBlastRadius(MutationRequest request, MutationRequestIntentFilter filter) - { - if (!filter.MinimumBlastRadiusScope.HasValue && !filter.MaximumBlastRadiusScope.HasValue) - return true; - - var scope = request.Intent.EstimatedBlastRadius?.Scope; - if (scope is null) - return false; - - if (filter.MinimumBlastRadiusScope.HasValue && scope.Value < filter.MinimumBlastRadiusScope.Value) - return false; - - if (filter.MaximumBlastRadiusScope.HasValue && scope.Value > filter.MaximumBlastRadiusScope.Value) - return false; - - return true; - } - - private static bool MatchesCreatedAt(MutationRequest request, MutationRequestTimeRangeFilter filter) - { - if (filter.CreatedFrom.HasValue && request.CreatedAt < filter.CreatedFrom.Value) - return false; - - if (filter.CreatedTo.HasValue && request.CreatedAt > filter.CreatedTo.Value) - return false; - - return true; - } - - private static bool MatchesUpdatedAt(MutationRequest request, MutationRequestTimeRangeFilter filter) - { - if (filter.UpdatedFrom.HasValue && request.UpdatedAt < filter.UpdatedFrom.Value) - return false; - - if (filter.UpdatedTo.HasValue && request.UpdatedAt > filter.UpdatedTo.Value) - return false; - - return true; - } - - private static bool MatchesDecisionCategories(MutationRequest request, MutationRequestLifecycleFilter filter) - => filter.DecisionCategories.Count == 0 || - request.Decisions.Any(decision => filter.DecisionCategories.Contains(decision.Type.Category)); - - private static bool MatchesMetadata( - IReadOnlyDictionary requestMetadata, - IReadOnlyDictionary queryMetadata) - { - foreach (var pair in queryMetadata) - { - if (!requestMetadata.TryGetValue(pair.Key, out var value)) - return false; - - if (!Equals(value, pair.Value)) - return false; - } - - return true; - } -} diff --git a/src/Governance/Abstractions/Requests/Model/MutationRequest.cs b/src/Governance/Abstractions/Requests/Model/MutationRequest.cs index e3f79f3..6b23bb7 100644 --- a/src/Governance/Abstractions/Requests/Model/MutationRequest.cs +++ b/src/Governance/Abstractions/Requests/Model/MutationRequest.cs @@ -1,4 +1,5 @@ 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.Approval.Model; @@ -67,6 +68,11 @@ public sealed record MutationRequest /// public IReadOnlyList Decisions { get; init; } = []; + /// + /// Side effects captured from governed execution results for this request. + /// + public IReadOnlyList SideEffects { get; init; } = []; + /// /// Optimistic concurrency revision for the governed request. /// @@ -106,4 +112,4 @@ public sealed record MutationRequest /// Additional governance metadata carried by the request. /// public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); -} \ No newline at end of file +} diff --git a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs index 3896187..ee78c20 100644 --- a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs +++ b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs @@ -70,48 +70,13 @@ public async Task> ExecuteApproved( } catch (Exception ex) { - await _outcomeHandler.PersistRejectedExecution( - execution.Resolution.Request, - execution.GovernanceContext, - $"Governed execution threw '{ex.GetType().Name}': {ex.Message}", - GovernedExecutionFailureMetadataFactory.CreateExceptionMetadata(execution.CurrentStateVersion, ex), - cancellationToken).ConfigureAwait(false); - + await _outcomeHandler.PersistException(execution, ex, cancellationToken).ConfigureAwait(false); throw; } - if (!mutationResult.IsSuccess || mutationResult.NewState is null) - { - var rejectedRequest = await _outcomeHandler.PersistRejectedExecution( - execution.Resolution.Request, - execution.GovernanceContext, - GovernedExecutionDecisionFactory.BuildRejectedExecutionReason(mutationResult), - GovernedExecutionFailureMetadataFactory.CreateRejectedExecutionMetadata( - execution.CurrentStateVersion, - mutationResult), - cancellationToken).ConfigureAwait(false); - - return _outcomeHandler.BuildNonExecutedResult( - execution.Resolution with { Request = rejectedRequest }, - mutationResult); - } - - var resultingStateVersion = execution.ResultingStateVersionProvider(mutationResult.NewState); - if (string.IsNullOrWhiteSpace(resultingStateVersion)) - throw new InvalidOperationException("Governed execution requires a non-empty resulting state version."); - - var executedRequest = await _outcomeHandler.PersistExecutedRequest( - execution.Resolution.Request, - resultingStateVersion, - execution.GovernanceContext, - mutationResult, - cancellationToken).ConfigureAwait(false); - - return _outcomeHandler.BuildExecutedResult( - execution.Resolution, - mutationResult, - executedRequest, - resultingStateVersion); + return await _outcomeHandler + .HandleMutationResult(execution, mutationResult, cancellationToken) + .ConfigureAwait(false); } public Task> ExecuteApproved( diff --git a/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionResultingStateVersionResolver.cs b/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionResultingStateVersionResolver.cs new file mode 100644 index 0000000..9268c6f --- /dev/null +++ b/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionResultingStateVersionResolver.cs @@ -0,0 +1,18 @@ +namespace ModularityKit.Mutator.Governance.Runtime.Execution.Orchestration; + +/// +/// Resolves and validates the resulting state version for governed execution. +/// +internal static class GovernedExecutionResultingStateVersionResolver +{ + public static string Resolve( + GovernedExecutionContext execution, + TState newState) + { + var resultingStateVersion = execution.ResultingStateVersionProvider(newState); + if (string.IsNullOrWhiteSpace(resultingStateVersion)) + throw new InvalidOperationException("Governed execution requires non empty resulting state version."); + + return resultingStateVersion; + } +} diff --git a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs index 74e7498..d960f36 100644 --- a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs +++ b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs @@ -1,9 +1,11 @@ using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Effects; using ModularityKit.Mutator.Abstractions.Results; using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; +using ModularityKit.Mutator.Governance.Runtime.Execution.Orchestration; using ModularityKit.Mutator.Governance.Runtime.Execution.Persistence; namespace ModularityKit.Mutator.Governance.Runtime.Execution.Outcome; @@ -15,11 +17,24 @@ internal sealed class GovernedExecutionOutcomeHandler(GovernedExecutionRequestPe { private readonly GovernedExecutionRequestPersistence _persistence = persistence ?? throw new ArgumentNullException(nameof(persistence)); + public async Task PersistException( + GovernedExecutionContext execution, Exception exception, CancellationToken cancellationToken) + { + await PersistRejectedExecution( + execution.Resolution.Request, + execution.GovernanceContext, + $"Governed execution threw '{exception.GetType().Name}': {exception.Message}", + GovernedExecutionFailureMetadataFactory.CreateExceptionMetadata(execution.CurrentStateVersion, exception), + sideEffects: null, + cancellationToken).ConfigureAwait(false); + } + public async Task PersistRejectedExecution( MutationRequest request, MutationContext governanceContext, string reason, IReadOnlyDictionary metadata, + IReadOnlyList? sideEffects, CancellationToken cancellationToken) { var decision = GovernedExecutionDecisionFactory.CreateRejectedDecision( @@ -32,7 +47,8 @@ public async Task PersistRejectedExecution( Status = MutationRequestStatus.Rejected, PendingReason = null, UpdatedAt = decision.Timestamp, - Decisions = [.. request.Decisions, decision] + Decisions = [.. request.Decisions, decision], + SideEffects = sideEffects ?? [] }; return await _persistence.Persist(request, rejectedRequest, cancellationToken).ConfigureAwait(false); @@ -58,32 +74,26 @@ public async Task PersistExecutedRequest( ResultingStateVersion = resultingStateVersion, ExecutedAt = decision.Timestamp, UpdatedAt = decision.Timestamp, - Decisions = [.. request.Decisions, decision] + Decisions = [.. request.Decisions, decision], + SideEffects = mutationResult.SideEffects.ToList() }; return await _persistence.Persist(request, executedRequest, cancellationToken).ConfigureAwait(false); } public GovernedExecutionResult BuildNonExecutedResult( - MutationRequestVersionResolution resolution, - MutationResult? mutationResult = null) - { - return new GovernedExecutionResult + MutationRequestVersionResolution resolution, MutationResult? mutationResult = null) => + new() { Request = resolution.Request, Resolution = resolution, MutationResult = mutationResult, WasExecuted = false }; - } - public GovernedExecutionResult BuildExecutedResult( - MutationRequestVersionResolution resolution, - MutationResult mutationResult, - MutationRequest executedRequest, - string resultingStateVersion) - { - return new GovernedExecutionResult + public GovernedExecutionResult BuildExecutedResult(MutationRequestVersionResolution resolution, + MutationResult mutationResult, MutationRequest executedRequest, string resultingStateVersion) => + new() { Request = executedRequest, Resolution = resolution with { Request = executedRequest }, @@ -91,5 +101,43 @@ public GovernedExecutionResult BuildExecutedResult( WasExecuted = true, ResultingStateVersion = resultingStateVersion }; + + + public async Task> HandleMutationResult( + GovernedExecutionContext execution, MutationResult mutationResult, CancellationToken cancellationToken) + { + if (!mutationResult.IsSuccess || mutationResult.NewState is null) + { + var rejectedRequest = await PersistRejectedExecution( + execution.Resolution.Request, + execution.GovernanceContext, + GovernedExecutionDecisionFactory.BuildRejectedExecutionReason(mutationResult), + GovernedExecutionFailureMetadataFactory.CreateRejectedExecutionMetadata( + execution.CurrentStateVersion, + mutationResult), + mutationResult.SideEffects, + cancellationToken).ConfigureAwait(false); + + return BuildNonExecutedResult( + execution.Resolution with { Request = rejectedRequest }, + mutationResult); + } + + var resultingStateVersion = GovernedExecutionResultingStateVersionResolver.Resolve( + execution, + mutationResult.NewState); + + var executedRequest = await PersistExecutedRequest( + execution.Resolution.Request, + resultingStateVersion, + execution.GovernanceContext, + mutationResult, + cancellationToken).ConfigureAwait(false); + + return BuildExecutedResult( + execution.Resolution, + mutationResult, + executedRequest, + resultingStateVersion); } } diff --git a/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs index 24352a0..6266756 100644 --- a/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs +++ b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs @@ -4,6 +4,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Storage; @@ -19,6 +20,19 @@ public sealed class InMemoryMutationRequestStore : IMutationRequestStore, IMutat private readonly Dictionary _requests = new(); private readonly Lock _lock = new(); + /// + /// Initializes new in-memory governed request store. + /// + public InMemoryMutationRequestStore() + { + } + + /// + /// Creates governed request in the in-memory store. + /// + /// The request to persist. + /// The cancellation token. + /// The persisted request snapshot. public Task Create( MutationRequest request, CancellationToken cancellationToken = default) @@ -40,6 +54,13 @@ public Task Create( } } + /// + /// Stores governed request when the expected revision matches the current revision. + /// + /// The request to persist. + /// The expected current revision. + /// The cancellation token. + /// The persisted request snapshot on success; otherwise . public Task TryStore( MutationRequest request, long expectedRevision, @@ -65,6 +86,12 @@ public Task Create( } } + /// + /// Gets request by its stable identifier. + /// + /// The request identifier. + /// The cancellation token. + /// The request snapshot when present; otherwise . public Task Get( string requestId, CancellationToken cancellationToken = default) @@ -76,6 +103,12 @@ public Task Create( } } + /// + /// Gets requests targeting specific state. + /// + /// The state identifier. + /// The cancellation token. + /// Requests ordered by creation time. public Task> GetByStateId( string stateId, CancellationToken cancellationToken = default) @@ -91,6 +124,12 @@ public Task> GetByStateId( } } + /// + /// Gets pending requests, optionally narrowed by pending reason. + /// + /// The optional pending reason. + /// The cancellation token. + /// Pending requests ordered by creation time. public Task> GetPending( PendingMutationReason? reason = null, CancellationToken cancellationToken = default) @@ -108,6 +147,13 @@ public Task> GetPending( } } + /// + /// Gets pending requests for specific state, optionally narrowed by pending reason. + /// + /// The state identifier. + /// The optional pending reason. + /// The cancellation token. + /// Pending requests ordered by creation time. public Task> GetPendingByStateId( string stateId, PendingMutationReason? reason = null, @@ -127,6 +173,12 @@ public Task> GetPendingByStateId( } } + /// + /// Queries governed requests using the supplied criteria. + /// + /// The query to evaluate. + /// The cancellation token. + /// The matching requests ordered by creation time and identifier. public Task> QueryAsync( MutationRequestQuery query, CancellationToken cancellationToken = default) @@ -145,6 +197,12 @@ public Task> QueryAsync( } } + /// + /// Gets pending requests, optionally narrowed by an additional query. + /// + /// The optional request query. + /// The cancellation token. + /// The matching pending requests. public Task> GetPendingRequestsAsync( MutationRequestQuery? query = null, CancellationToken cancellationToken = default) @@ -162,6 +220,12 @@ public Task> GetPendingRequestsAsync( } } + /// + /// Gets the pending approval queue, optionally narrowed by an additional query. + /// + /// The optional request query. + /// The cancellation token. + /// The matching pending approval queue requests. public Task> GetPendingApprovalQueueAsync( MutationRequestQuery? query = null, CancellationToken cancellationToken = default) @@ -181,6 +245,13 @@ public Task> GetPendingApprovalQueueAsync( } } + /// + /// Gets recent approval driven requests, optionally narrowed by an additional query. + /// + /// The optional request query. + /// The optional maximum number of results to return. + /// The cancellation token. + /// The matching approval-driven requests ordered from newest activity to oldest. public Task> GetRecentApprovalsAsync( MutationRequestQuery? query = null, int? take = null, @@ -205,6 +276,12 @@ public Task> GetRecentApprovalsAsync( } } + /// + /// Gets approval centric views for pending governed requests. + /// + /// The optional approval query. + /// The cancellation token. + /// The matching approval views ordered for queue processing. public Task> GetPendingApprovalsAsync( MutationApprovalQuery? query = null, CancellationToken cancellationToken = default) @@ -230,6 +307,13 @@ public Task> GetPendingApprovalsAsync( } } + /// + /// Gets recent decision-centric views across governed requests. + /// + /// The optional decision query. + /// The optional maximum number of results to return. + /// The cancellation token. + /// The matching decision views ordered from newest to oldest. public Task> GetRecentDecisionsAsync( MutationRequestDecisionQuery? query = null, int? take = null, diff --git a/src/Governance/Runtime/Storage/Persistence/InMemoryMutationRequestPersistence.cs b/src/Governance/Runtime/Storage/Persistence/InMemoryMutationRequestPersistence.cs new file mode 100644 index 0000000..975acd6 --- /dev/null +++ b/src/Governance/Runtime/Storage/Persistence/InMemoryMutationRequestPersistence.cs @@ -0,0 +1,116 @@ +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Runtime.Storage.Persistence; + +/// +/// Handles write-side and direct lookup operations for in-memory governed requests. +/// +internal sealed class InMemoryMutationRequestPersistence(InMemoryMutationRequestSnapshotSource snapshotSource) +{ + private readonly InMemoryMutationRequestSnapshotSource _snapshotSource = + snapshotSource ?? throw new ArgumentNullException(nameof(snapshotSource)); + + /// + /// Creates governed request in in-memory store. + /// + /// The request to create. + /// The persisted request snapshot. + public MutationRequest Create(MutationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + return _snapshotSource.Write(requests => + { + if (requests.ContainsKey(request.RequestId)) + throw new MutationRequestAlreadyExistsException(request.RequestId); + + var persistedRequest = request with + { + Revision = 0 + }; + + requests[request.RequestId] = persistedRequest; + return persistedRequest; + }); + } + + /// + /// Stores governed request when the expected revision matches the current revision. + /// + /// The request to store. + /// The expected current revision. + /// The persisted request snapshot on success, or on conflict. + public MutationRequest? TryStore(MutationRequest request, long expectedRevision) + { + ArgumentNullException.ThrowIfNull(request); + + return _snapshotSource.Write(requests => + { + if (!requests.TryGetValue(request.RequestId, out var currentRequest)) + return null; + + if (currentRequest.Revision != expectedRevision) + return null; + + var persistedRequest = request with + { + Revision = expectedRevision + 1 + }; + + requests[request.RequestId] = persistedRequest; + return persistedRequest; + }); + } + + /// + /// Gets request by stable identifier. + /// + /// The request identifier. + /// The request snapshot when present; otherwise . + public MutationRequest? Get(string requestId) + => _snapshotSource.Read(requests => + { + requests.TryGetValue(requestId, out var request); + return request; + }); + + /// + /// Gets requests targeting specific state. + /// + /// The state identifier. + /// Requests ordered by creation time. + public IReadOnlyList GetByStateId(string stateId) + => _snapshotSource.Read>(requests => [.. requests.Values + .Where(request => request.StateId == stateId) + .OrderBy(request => request.CreatedAt)]); + + /// + /// Gets pending requests, optionally narrowed by pending reason. + /// + /// The optional pending reason. + /// Pending requests ordered by creation time. + public IReadOnlyList GetPending(PendingMutationReason? reason = null) + => _snapshotSource.Read>(requests => [.. requests.Values + .Where(request => + request.Status == MutationRequestStatus.Pending && + (reason is null || request.PendingReason == reason)) + .OrderBy(request => request.CreatedAt)]); + + /// + /// Gets pending requests for specific state, optionally narrowed by pending reason. + /// + /// The state identifier. + /// The optional pending reason. + /// Pending requests ordered by creation time. + public IReadOnlyList GetPendingByStateId( + string stateId, + PendingMutationReason? reason = null) + => _snapshotSource.Read>(requests => [.. requests.Values + .Where(request => + request.StateId == stateId && + request.Status == MutationRequestStatus.Pending && + (reason is null || request.PendingReason == reason)) + .OrderBy(request => request.CreatedAt)]); +} diff --git a/src/Governance/Runtime/Storage/Persistence/InMemoryMutationRequestSnapshotSource.cs b/src/Governance/Runtime/Storage/Persistence/InMemoryMutationRequestSnapshotSource.cs new file mode 100644 index 0000000..93bb5bc --- /dev/null +++ b/src/Governance/Runtime/Storage/Persistence/InMemoryMutationRequestSnapshotSource.cs @@ -0,0 +1,44 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Runtime.Storage.Persistence; + +/// +/// Owns the in-memory governed request collection and its synchronization boundary. +/// +internal sealed class InMemoryMutationRequestSnapshotSource +{ + private readonly Dictionary _requests = new(); + private readonly Lock _lock = new(); + + /// + /// Executes read operation against the in-memory request collection under the storage lock. + /// + /// The result type returned by the read operation. + /// The read operation to execute. + /// The result produced by the read operation. + public T Read(Func, T> read) + { + ArgumentNullException.ThrowIfNull(read); + + lock (_lock) + { + return read(_requests); + } + } + + /// + /// Executes write operation against the in-memory request collection under the storage lock. + /// + /// The result type returned by the write operation. + /// The write operation to execute. + /// The result produced by the write operation. + public T Write(Func, T> write) + { + ArgumentNullException.ThrowIfNull(write); + + lock (_lock) + { + return write(_requests); + } + } +} diff --git a/src/Governance/Runtime/Storage/Queries/Materialization/InMemoryMutationRequestOrdering.cs b/src/Governance/Runtime/Storage/Queries/Materialization/InMemoryMutationRequestOrdering.cs new file mode 100644 index 0000000..8f91007 --- /dev/null +++ b/src/Governance/Runtime/Storage/Queries/Materialization/InMemoryMutationRequestOrdering.cs @@ -0,0 +1,77 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Runtime.Storage.Queries.Materialization; + +/// +/// Applies common ordering rules for in-memory governance query results. +/// +internal static class InMemoryMutationRequestOrdering +{ + /// + /// Orders request results by creation time and request identifier. + /// + /// Requests to order. + /// Materialized request results in ascending creation order. + public static IReadOnlyList ByCreated(IEnumerable requests) + => [.. requests + .OrderBy(request => request.CreatedAt) + .ThenBy(request => request.RequestId)]; + + /// + /// Orders requests by the most recent approval activity and applies an optional result limit. + /// + /// Requests to order. + /// Optional maximum number of results to return. + /// Materialized request results ordered for recent approval views. + public static IReadOnlyList ByRecentApprovals( + IEnumerable requests, + int? take) + { + IEnumerable results = requests + .OrderByDescending(MutationRequestQueryEvaluator.GetRecentApprovalTimestamp) + .ThenByDescending(request => request.UpdatedAt) + .ThenBy(request => request.RequestId); + + if (take is >= 0) + results = results.Take(take.Value); + + return [.. results]; + } + + /// + /// Orders pending approval projections by request creation and approval step sequence. + /// + /// Approval views to order. + /// Materialized approval views in pending queue order. + public static IReadOnlyList ByPendingApprovalView( + IEnumerable views) + => [.. views + .OrderBy(view => view.Request.CreatedAt) + .ThenBy(view => view.Request.RequestId) + .ThenBy(view => view.Approval.StepOrder) + .ThenBy(view => view.Approval.ApprovalId)]; + + /// + /// Orders decision projections by decision recency and applies an optional result limit. + /// + /// Decision views to order. + /// Optional maximum number of results to return. + /// Materialized decision views ordered from newest to oldest. + public static IReadOnlyList ByRecentDecisionView( + IEnumerable views, + int? take) + { + IEnumerable results = views + .OrderByDescending(view => view.Decision.Timestamp) + .ThenByDescending(view => view.Request.UpdatedAt) + .ThenBy(view => view.Request.RequestId); + + if (take is >= 0) + results = results.Take(take.Value); + + return [.. results]; + } +} diff --git a/src/Governance/Runtime/Storage/Queries/Materialization/InMemoryMutationRequestQueryMaterializer.cs b/src/Governance/Runtime/Storage/Queries/Materialization/InMemoryMutationRequestQueryMaterializer.cs new file mode 100644 index 0000000..d5a6b79 --- /dev/null +++ b/src/Governance/Runtime/Storage/Queries/Materialization/InMemoryMutationRequestQueryMaterializer.cs @@ -0,0 +1,135 @@ +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.Evaluation; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Runtime.Storage.Queries.Materialization; + +/// +/// Applies governance query evaluators to in-memory request snapshots. +/// +internal static class InMemoryMutationRequestQueryMaterializer +{ + /// + /// Applies general request query to in-memory request snapshots. + /// + /// The in-memory request snapshots. + /// The query to evaluate. + /// The filtered request results. + public static IReadOnlyList ApplyQuery( + IEnumerable requests, + MutationRequestQuery query) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentNullException.ThrowIfNull(query); + + return InMemoryMutationRequestOrdering.ByCreated( + requests.Where(request => MutationRequestQueryEvaluator.Matches(request, query))); + } + + /// + /// Applies pending request query to in-memory request snapshots. + /// + /// The in-memory request snapshots. + /// The query to evaluate. + /// The filtered pending-request results. + public static IReadOnlyList ApplyPendingQuery( + IEnumerable requests, + MutationRequestQuery query) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentNullException.ThrowIfNull(query); + + return InMemoryMutationRequestOrdering.ByCreated( + requests.Where(request => + request.Status == MutationRequestStatus.Pending && + MutationRequestQueryEvaluator.Matches(request, query))); + } + + /// + /// Applies pending approval queue query to in-memory request snapshots. + /// + /// The in-memory request snapshots. + /// The query to evaluate. + /// The filtered pending-approval-queue results. + public static IReadOnlyList ApplyPendingApprovalQueueQuery( + IEnumerable requests, + MutationRequestQuery query) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentNullException.ThrowIfNull(query); + + return InMemoryMutationRequestOrdering.ByCreated( + requests.Where(request => + request.Status == MutationRequestStatus.Pending && + request.PendingReason == PendingMutationReason.Approval && + MutationRequestQueryEvaluator.Matches(request, query))); + } + + /// + /// Applies recent approvals query to in-memory request snapshots. + /// + /// The in-memory request snapshots. + /// The query to evaluate. + /// An optional result limit. + /// The filtered recent-approval results. + public static IReadOnlyList ApplyRecentApprovalsQuery( + IEnumerable requests, + MutationRequestQuery query, + int? take) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentNullException.ThrowIfNull(query); + + var results = requests + .Where(request => + MutationRequestQueryEvaluator.Matches(request, query) && + MutationRequestQueryEvaluator.HasApprovalActivity(request)); + + return InMemoryMutationRequestOrdering.ByRecentApprovals(results, take); + } + + /// + /// Applies pending approval view query to in-memory request snapshots. + /// + /// The in-memory request snapshots. + /// The approval query to evaluate. + /// The filtered approval-view results. + public static IReadOnlyList ApplyPendingApprovalViewQuery( + IEnumerable requests, + MutationApprovalQuery query) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentNullException.ThrowIfNull(query); + + var views = InMemoryMutationRequestViewProjector + .ToApprovalViews(requests) + .Where(view => MutationApprovalQueryEvaluator.Matches(view.Request, view.Approval, query)); + + return InMemoryMutationRequestOrdering.ByPendingApprovalView(views); + } + + /// + /// Applies recent decision query to in-memory request snapshots. + /// + /// The in-memory request snapshots. + /// The decision query to evaluate. + /// An optional result limit. + /// The filtered decision-view results. + public static IReadOnlyList ApplyRecentDecisionQuery( + IEnumerable requests, + MutationRequestDecisionQuery query, + int? take) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentNullException.ThrowIfNull(query); + + var views = InMemoryMutationRequestViewProjector + .ToDecisionViews(requests) + .Where(view => MutationRequestDecisionQueryEvaluator.Matches(view.Request, view.Decision, query)); + + return InMemoryMutationRequestOrdering.ByRecentDecisionView(views, take); + } +} diff --git a/src/Governance/Runtime/Storage/Queries/Materialization/InMemoryMutationRequestViewProjector.cs b/src/Governance/Runtime/Storage/Queries/Materialization/InMemoryMutationRequestViewProjector.cs new file mode 100644 index 0000000..590be08 --- /dev/null +++ b/src/Governance/Runtime/Storage/Queries/Materialization/InMemoryMutationRequestViewProjector.cs @@ -0,0 +1,43 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Runtime.Storage.Queries.Materialization; + +/// +/// Projects governed requests into query-specific in-memory views. +/// +internal static class InMemoryMutationRequestViewProjector +{ + /// + /// Projects governed requests into approval-centric query views. + /// + /// Requests to project. + /// Approval views produced from request approval requirements. + public static IEnumerable ToApprovalViews(IEnumerable requests) + { + ArgumentNullException.ThrowIfNull(requests); + + return requests.SelectMany(request => request.ApprovalRequirements.Select(approval => new MutationApprovalView + { + Request = request, + Approval = approval + })); + } + + /// + /// Projects governed requests into decision-centric query views. + /// + /// Requests to project. + /// Decision views produced from request history entries. + public static IEnumerable ToDecisionViews(IEnumerable requests) + { + ArgumentNullException.ThrowIfNull(requests); + + return requests.SelectMany(request => request.Decisions.Select(decision => new MutationRequestDecisionView + { + Request = request, + Decision = decision + })); + } +} diff --git a/src/Governance/Runtime/Storage/Reading/InMemoryMutationRequestReader.cs b/src/Governance/Runtime/Storage/Reading/InMemoryMutationRequestReader.cs new file mode 100644 index 0000000..e2e692d --- /dev/null +++ b/src/Governance/Runtime/Storage/Reading/InMemoryMutationRequestReader.cs @@ -0,0 +1,101 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Runtime.Storage.Persistence; +using ModularityKit.Mutator.Governance.Runtime.Storage.Queries.Materialization; + +namespace ModularityKit.Mutator.Governance.Runtime.Storage.Reading; + +/// +/// Handles query oriented reads for in-memory governed requests. +/// +internal sealed class InMemoryMutationRequestReader(InMemoryMutationRequestSnapshotSource snapshotSource) +{ + private readonly InMemoryMutationRequestSnapshotSource _snapshotSource = + snapshotSource ?? throw new ArgumentNullException(nameof(snapshotSource)); + + /// + /// Queries governed requests using the supplied criteria. + /// + /// The query to evaluate. + /// The matching requests. + public IReadOnlyList Query(MutationRequestQuery query) + { + ArgumentNullException.ThrowIfNull(query); + + return _snapshotSource.Read(requests => + InMemoryMutationRequestQueryMaterializer.ApplyQuery(requests.Values, query)); + } + + /// + /// Gets pending governed requests, optionally narrowed by additional criteria. + /// + /// The optional request query. + /// The matching pending requests. + public IReadOnlyList GetPendingRequests(MutationRequestQuery? query = null) + { + var effectiveQuery = query ?? new MutationRequestQuery(); + + return _snapshotSource.Read(requests => + InMemoryMutationRequestQueryMaterializer.ApplyPendingQuery(requests.Values, effectiveQuery)); + } + + /// + /// Gets the pending approval queue, optionally narrowed by additional criteria. + /// + /// The optional request query. + /// The matching pending approval queue requests. + public IReadOnlyList GetPendingApprovalQueue(MutationRequestQuery? query = null) + { + var effectiveQuery = query ?? new MutationRequestQuery(); + + return _snapshotSource.Read(requests => + InMemoryMutationRequestQueryMaterializer.ApplyPendingApprovalQueueQuery(requests.Values, effectiveQuery)); + } + + /// + /// Gets recent approval-driven requests, optionally narrowed by additional criteria. + /// + /// The optional request query. + /// The optional maximum number of results to return. + /// The matching recent approval requests. + public IReadOnlyList GetRecentApprovals( + MutationRequestQuery? query = null, + int? take = null) + { + var effectiveQuery = query ?? MutationRequestQueries.RecentApprovals(); + + return _snapshotSource.Read(requests => + InMemoryMutationRequestQueryMaterializer.ApplyRecentApprovalsQuery(requests.Values, effectiveQuery, take)); + } + + /// + /// Gets approval-oriented projections for governed requests. + /// + /// The optional approval query. + /// The matching approval views. + public IReadOnlyList GetPendingApprovals(MutationApprovalQuery? query = null) + { + var effectiveQuery = query ?? MutationApprovalQuery.Pending(); + + return _snapshotSource.Read(requests => + InMemoryMutationRequestQueryMaterializer.ApplyPendingApprovalViewQuery(requests.Values, effectiveQuery)); + } + + /// + /// Gets recent decision-oriented projections across governed requests. + /// + /// The optional decision query. + /// The optional maximum number of results to return. + /// The matching decision views. + public IReadOnlyList GetRecentDecisions( + MutationRequestDecisionQuery? query = null, + int? take = null) + { + var effectiveQuery = query ?? new MutationRequestDecisionQuery(); + + return _snapshotSource.Read(requests => + InMemoryMutationRequestQueryMaterializer.ApplyRecentDecisionQuery(requests.Values, effectiveQuery, take)); + } +} diff --git a/src/README.md b/src/README.md index 98ae551..8920581 100644 --- a/src/README.md +++ b/src/README.md @@ -102,6 +102,56 @@ Core runtime concurrency is controlled by `MutationEngineOptions.MaxConcurrentMu - `PolicyDecision` - `PolicyRequirement` +### Async policies and external checks + +`IMutationPolicy` supports both synchronous and asynchronous evaluation. + +- implement `Evaluate(...)` for lightweight in-process rules +- implement `EvaluateAsync(..., CancellationToken)` for external identity, ticketing, quota, or compliance checks +- the runtime evaluates policies in descending `Priority` order +- sync and async policies can be registered together; they participate in the same priority-ordered pipeline +- when `MutationEngineOptions.PolicyEvaluationTimeout` is set, the timeout is applied per policy evaluation +- caller cancellation still flows through unchanged +- policy failures are surfaced as `PolicyEvaluationException` +- policy timeouts are surfaced as `PolicyEvaluationTimeoutException` + +Typical integration cases include: + +- external approval evidence checks +- actor/identity resolution against IAM or directory systems +- ticket status validation before execution +- quota lookups in remote control planes +- compliance or risk verification before commit + +```csharp +public sealed class RequireApprovedTicketPolicy : IMutationPolicy +{ + private readonly ITicketGateway _tickets; + + public RequireApprovedTicketPolicy(ITicketGateway tickets) + { + _tickets = tickets; + } + + public string Name => "RequireApprovedTicket"; + public int Priority => 200; + public string? Description => "Checks ticket approval status before quota changes execute."; + + public async Task EvaluateAsync( + IMutation mutation, + QuotaState state, + CancellationToken cancellationToken = default) + { + var ticketId = mutation.Context.CorrelationId; + var approved = await _tickets.IsApprovedAsync(ticketId!, cancellationToken); + + return approved + ? PolicyDecision.Allow(Name, "External ticket is approved.") + : PolicyDecision.Deny("External ticket is not approved.", Name); + } +} +``` + ### Results and changes - `MutationResult` @@ -109,6 +159,37 @@ Core runtime concurrency is controlled by `MutationEngineOptions.MaxConcurrentMu - `ValidationResult` - `ChangeSet` - `StateChange` +- `SideEffect` +- `SideEffectDataContractAttribute` +- `SideEffectDataContractRegistry` + +### Typed side effects + +Use typed side effect payloads when the emitted data is meant to survive serialization, audit, or downstream integration: + +```csharp +[SideEffectDataContract("workflow.started", 1)] +public sealed record WorkflowStartedSideEffectData +{ + public required string Initiator { get; init; } + public required int StepCount { get; init; } + public required string WorkflowId { get; init; } +} + +SideEffectDataContractRegistry.Register(); + +var effect = SideEffect.Create( + type: "WorkflowStarted", + description: "Approval workflow started", + data: new WorkflowStartedSideEffectData + { + Initiator = "alice", + StepCount = 2, + WorkflowId = "wf-42" + }); +``` + +The side effect keeps `DataContractType` and `DataContractVersion` alongside `Data`, so persistence and integration layers do not have to guess the payload shape. ### Context and intent diff --git a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs index 033076f..93b9d3f 100644 --- a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs +++ b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs @@ -1,6 +1,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; @@ -16,10 +17,9 @@ internal static class RedisMutationRequestOrdering /// Requests to order. /// Materialized request results in ascending creation order. public static IReadOnlyList ByCreated(IEnumerable requests) - => requests + => [.. requests .OrderBy(request => request.CreatedAt) - .ThenBy(request => request.RequestId) - .ToList(); + .ThenBy(request => request.RequestId)]; /// /// Orders requests by the most recent approval activity and applies an optional result limit. @@ -39,7 +39,7 @@ public static IReadOnlyList ByRecentApprovals( if (take is >= 0) results = results.Take(take.Value); - return results.ToList(); + return [.. results]; } /// @@ -49,12 +49,11 @@ public static IReadOnlyList ByRecentApprovals( /// Materialized approval views in pending queue order. public static IReadOnlyList ByPendingApprovalView( IEnumerable views) - => views + => [.. views .OrderBy(view => view.Request.CreatedAt) .ThenBy(view => view.Request.RequestId) .ThenBy(view => view.Approval.StepOrder) - .ThenBy(view => view.Approval.ApprovalId) - .ToList(); + .ThenBy(view => view.Approval.ApprovalId)]; /// /// Orders decision projections by decision recency and applies an optional result limit. @@ -74,6 +73,6 @@ public static IReadOnlyList ByRecentDecisionView( if (take is >= 0) results = results.Take(take.Value); - return results.ToList(); + return [.. results]; } } diff --git a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs index b5ff76e..3f3ee87 100644 --- a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs +++ b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs @@ -2,6 +2,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Evaluation; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; diff --git a/src/Runtime/Internal/MutationAuditEntryFactory.cs b/src/Runtime/Diagnostics/MutationAuditEntryFactory.cs similarity index 57% rename from src/Runtime/Internal/MutationAuditEntryFactory.cs rename to src/Runtime/Diagnostics/MutationAuditEntryFactory.cs index 944097a..2ef5afa 100644 --- a/src/Runtime/Internal/MutationAuditEntryFactory.cs +++ b/src/Runtime/Diagnostics/MutationAuditEntryFactory.cs @@ -7,10 +7,23 @@ using ModularityKit.Mutator.Abstractions.Policies; using ModularityKit.Mutator.Abstractions.Results; -namespace ModularityKit.Mutator.Runtime.Internal; +namespace ModularityKit.Mutator.Runtime.Diagnostics; +/// +/// Factory for creating audit and history entries for mutations. +/// internal static class MutationAuditEntryFactory { + /// + /// Creates a successful mutation audit entry. + /// + /// The state type handled by the mutation. + /// The mutation that was executed. + /// The result of the mutation execution. + /// The policy decision applied to the mutation. + /// The unique identifier of the execution. + /// The execution duration. + /// A configured representing success. public static MutationAuditEntry CreateSuccess( IMutation mutation, MutationResult result, @@ -30,6 +43,15 @@ public static MutationAuditEntry CreateSuccess( userAgent: mutation.Context.UserAgent); } + /// + /// Creates a failed mutation audit entry. + /// + /// The state type handled by the mutation. + /// The mutation that was executed. + /// The result of the mutation execution. + /// The unique identifier of the execution. + /// The execution duration. + /// A configured representing failure. public static MutationAuditEntry CreateFailure( IMutation mutation, MutationResult result, @@ -47,6 +69,15 @@ public static MutationAuditEntry CreateFailure( sideEffects: result.SideEffects); } + /// + /// Creates a failed mutation audit entry due to an exception. + /// + /// The state type handled by the mutation. + /// The mutation that was executed. + /// The exception that occurred. + /// The unique identifier of the execution. + /// The execution duration. + /// A configured representing an exception failure. public static MutationAuditEntry CreateException( IMutation mutation, Exception exception, @@ -61,6 +92,16 @@ public static MutationAuditEntry CreateException( errorMessage: exception.Message); } + /// + /// Creates a mutation history entry for persistence. + /// + /// The state type handled by the mutation. + /// The mutation that was executed. + /// The result of the mutation execution. + /// The unique identifier of the execution. + /// The identifier of the target state. + /// The execution duration. + /// A configured . public static MutationHistoryEntry CreateHistoryEntry( IMutation mutation, MutationResult result, @@ -81,9 +122,17 @@ public static MutationHistoryEntry CreateHistoryEntry( }; } + /// + /// Resolves the state identifier from the mutation context. + /// + /// The mutation context. + /// The resolved state ID or correlation ID. public static string? ResolveStateId(MutationContext context) => context.StateId ?? context.CorrelationId; + /// + /// Helper method to create a mutation audit entry. + /// private static MutationAuditEntry Create( IMutation mutation, string executionId, diff --git a/src/Runtime/Internal/StateSizeEstimator.cs b/src/Runtime/Diagnostics/StateSizeEstimator.cs similarity index 61% rename from src/Runtime/Internal/StateSizeEstimator.cs rename to src/Runtime/Diagnostics/StateSizeEstimator.cs index 9a70b2c..f60d1dd 100644 --- a/src/Runtime/Internal/StateSizeEstimator.cs +++ b/src/Runtime/Diagnostics/StateSizeEstimator.cs @@ -1,8 +1,11 @@ using System.Collections; using System.Text; -namespace ModularityKit.Mutator.Runtime.Internal; +namespace ModularityKit.Mutator.Runtime.Diagnostics; +/// +/// Provides a best-effort estimate of the in-memory size of a state object in bytes. +/// internal static class StateSizeEstimator { private static readonly IReadOnlyDictionary PrimitiveTypeSizes = new Dictionary @@ -23,6 +26,14 @@ internal static class StateSizeEstimator [typeof(Guid)] = 16 }; + /// + /// Estimates the size of the given state in bytes. + /// + /// The state object to estimate. Can be . + /// + /// The estimated byte size: UTF-8 byte count for strings, byte length for primitive arrays, + /// element count for collections, or 0 for unrecognized or null values. + /// public static long Estimate(object? state) { if (state is null) @@ -37,6 +48,12 @@ public static long Estimate(object? state) return state is ICollection collection ? collection.Count : 0; } + /// + /// Attempts to estimate the byte size of a primitive array. + /// + /// The object to inspect. + /// When successful, contains the estimated byte size of the array. + /// if is a primitive array with a known element size; otherwise . private static bool TryEstimateArraySize(object state, out long sizeInBytes) { sizeInBytes = 0; diff --git a/src/Runtime/Internal/Evaluation/MutationPolicyEvaluator.cs b/src/Runtime/Internal/Evaluation/MutationPolicyEvaluator.cs new file mode 100644 index 0000000..fa3f85b --- /dev/null +++ b/src/Runtime/Internal/Evaluation/MutationPolicyEvaluator.cs @@ -0,0 +1,107 @@ +using ModularityKit.Mutator.Abstractions; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Exceptions; +using ModularityKit.Mutator.Abstractions.Policies; + +namespace ModularityKit.Mutator.Runtime.Internal.Evaluation; + +/// +/// Evaluates registered mutation policies in runtime priority order. +/// +internal sealed class MutationPolicyEvaluator( + IPolicyRegistry policyRegistry, + MutationEngineOptions options) +{ + private readonly IPolicyRegistry _policyRegistry = policyRegistry ?? throw new ArgumentNullException(nameof(policyRegistry)); + private readonly MutationEngineOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Evaluates all registered policies for the supplied mutation and state. + /// + /// The state type handled by the mutation. + /// The mutation being evaluated. + /// The current state snapshot. + /// Token used to cancel policy evaluation. + /// + /// The first blocking or modifying , or an allow decision when all policies pass. + /// + public async Task EvaluateAsync( + IMutation mutation, + TState state, + CancellationToken cancellationToken) + { + var policies = _policyRegistry.GetPolicies(); + + foreach (var policy in policies.OrderByDescending(p => p.Priority)) + { + var decision = await EvaluatePolicyAsync( + policy, + mutation, + state, + cancellationToken).ConfigureAwait(false); + + if (!decision.IsAllowed || decision.Modifications != null) + return decision; + } + + return PolicyDecision.Allow(); + } + + private async Task EvaluatePolicyAsync( + IMutationPolicy policy, + IMutation mutation, + TState state, + CancellationToken cancellationToken) + { + if (!_options.PolicyEvaluationTimeout.HasValue) + return await InvokePolicyAsync(policy, mutation, state, cancellationToken).ConfigureAwait(false); + + using var timeoutSource = new CancellationTokenSource(_options.PolicyEvaluationTimeout.Value); + using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutSource.Token); + + try + { + return await policy.EvaluateAsync(mutation, state, linkedSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) when (timeoutSource.IsCancellationRequested) + { + throw new PolicyEvaluationTimeoutException(policy.Name, _options.PolicyEvaluationTimeout.Value); + } + catch (Exception ex) + { + throw new PolicyEvaluationException( + policy.Name, + $"Policy '{policy.Name}' evaluation failed: {ex.Message}", + ex); + } + } + + private static async Task InvokePolicyAsync( + IMutationPolicy policy, + IMutation mutation, + TState state, + CancellationToken cancellationToken) + { + try + { + return await policy.EvaluateAsync(mutation, state, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + throw new PolicyEvaluationException( + policy.Name, + $"Policy '{policy.Name}' evaluation failed: {ex.Message}", + ex); + } + } +} diff --git a/src/Runtime/Internal/PolicyModificationApplier.cs b/src/Runtime/Internal/Evaluation/PolicyModificationApplier.cs similarity index 60% rename from src/Runtime/Internal/PolicyModificationApplier.cs rename to src/Runtime/Internal/Evaluation/PolicyModificationApplier.cs index f0dba87..3ba002d 100644 --- a/src/Runtime/Internal/PolicyModificationApplier.cs +++ b/src/Runtime/Internal/Evaluation/PolicyModificationApplier.cs @@ -1,10 +1,27 @@ using ModularityKit.Mutator.Abstractions.Effects; using ModularityKit.Mutator.Abstractions.Results; -namespace ModularityKit.Mutator.Runtime.Internal; +namespace ModularityKit.Mutator.Runtime.Internal.Evaluation; +/// +/// Applies policy-level state and side-effect modifications to a mutation result. +/// internal static class PolicyModificationApplier { + /// + /// Applies the given policy modifications to , returning an updated result. + /// + /// The state type handled by the mutation. + /// The original mutation result to apply modifications to. + /// + /// A dictionary of modifications. Recognised keys are "State" (overrides the new state), + /// "SideEffect" (appends a single ), and + /// "SideEffects" (appends a collection of ). + /// + /// + /// The original unchanged when no applicable modifications exist or the result is not successful; + /// otherwise a new result record with the modifications applied. + /// public static MutationResult Apply( MutationResult result, IReadOnlyDictionary? modifications) diff --git a/src/Runtime/Internal/MutationBatchExecutor.cs b/src/Runtime/Internal/Execution/MutationBatchExecutor.cs similarity index 57% rename from src/Runtime/Internal/MutationBatchExecutor.cs rename to src/Runtime/Internal/Execution/MutationBatchExecutor.cs index e4ab27c..4f97bd2 100644 --- a/src/Runtime/Internal/MutationBatchExecutor.cs +++ b/src/Runtime/Internal/Execution/MutationBatchExecutor.cs @@ -3,10 +3,27 @@ using ModularityKit.Mutator.Abstractions.Results; using System.Diagnostics; -namespace ModularityKit.Mutator.Runtime.Internal; +namespace ModularityKit.Mutator.Runtime.Internal.Execution; +/// +/// Executes a sequence of mutations against an evolving state, accumulating results and changes. +/// internal static class MutationBatchExecutor { + /// + /// Iterates over in order, executing each against the current state. + /// Successful mutations advance the state; failed mutations are recorded and may halt the batch. + /// + /// The state type handled by the mutations. + /// The ordered sequence of mutations to execute. + /// The starting state before any mutation is applied. + /// When , halts execution after the first unsuccessful mutation. + /// The delegate used to execute a single mutation against the current state. + /// Token used to cancel batch execution. + /// + /// A containing all individual results, the aggregated + /// change set, the final state (when all mutations succeeded), and the total execution time. + /// public static async Task> ExecuteAsync( IEnumerable> mutations, TState initialState, diff --git a/src/Runtime/Internal/MutationExecutionConcurrencyGate.cs b/src/Runtime/Internal/Execution/MutationExecutionConcurrencyGate.cs similarity index 96% rename from src/Runtime/Internal/MutationExecutionConcurrencyGate.cs rename to src/Runtime/Internal/Execution/MutationExecutionConcurrencyGate.cs index 1bfb7fe..74dd141 100644 --- a/src/Runtime/Internal/MutationExecutionConcurrencyGate.cs +++ b/src/Runtime/Internal/Execution/MutationExecutionConcurrencyGate.cs @@ -1,6 +1,6 @@ using System.Collections.Concurrent; -namespace ModularityKit.Mutator.Runtime.Internal; +namespace ModularityKit.Mutator.Runtime.Internal.Execution; /// /// Coordinates core mutation execution concurrency across the engine. diff --git a/src/Runtime/Internal/Execution/MutationExecutionContext.cs b/src/Runtime/Internal/Execution/MutationExecutionContext.cs new file mode 100644 index 0000000..fe23fe5 --- /dev/null +++ b/src/Runtime/Internal/Execution/MutationExecutionContext.cs @@ -0,0 +1,42 @@ +using System.Diagnostics; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Metrics; + +namespace ModularityKit.Mutator.Runtime.Internal.Execution; + +/// +/// Carries shared execution state across the runtime mutation pipeline. +/// +/// The state type handled by the mutation. +internal sealed record MutationExecutionContext +{ + /// + /// The mutation being executed. + /// + public IMutation Mutation { get; init; } = null!; + + /// + /// The current state snapshot being mutated. + /// + public TState State { get; init; } = default!; + + /// + /// The unique identifier for this execution run. + /// + public string ExecutionId { get; init; } = string.Empty; + + /// + /// The shared stopwatch tracking total execution time. + /// + public Stopwatch Stopwatch { get; init; } = null!; + + /// + /// The optional metrics scope for detailed runtime metrics. + /// + public IMetricsScope? MetricsScope { get; init; } + + /// + /// The cancellation token for the current execution. + /// + public CancellationToken CancellationToken { get; init; } +} diff --git a/src/Runtime/Internal/Execution/MutationExecutionFailureHandler.cs b/src/Runtime/Internal/Execution/MutationExecutionFailureHandler.cs new file mode 100644 index 0000000..0825c6e --- /dev/null +++ b/src/Runtime/Internal/Execution/MutationExecutionFailureHandler.cs @@ -0,0 +1,106 @@ +using ModularityKit.Mutator.Abstractions.Audit; +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Exceptions; +using ModularityKit.Mutator.Abstractions.Interception; +using ModularityKit.Mutator.Runtime.Diagnostics; + +namespace ModularityKit.Mutator.Runtime.Internal.Execution; + +/// +/// Centralizes interceptor notification and audit persistence for mutation execution failures. +/// +internal sealed class MutationExecutionFailureHandler( + IInterceptorPipeline interceptorPipeline, + IMutationAuditor auditor) +{ + private readonly IInterceptorPipeline _interceptorPipeline = + interceptorPipeline ?? throw new ArgumentNullException(nameof(interceptorPipeline)); + + private readonly IMutationAuditor _auditor = auditor ?? throw new ArgumentNullException(nameof(auditor)); + + /// + /// Processes known mutation exception without wrapping it again. + /// + /// The state type handled by the mutation. + /// The shared execution context for the failed mutation. + /// The known mutation exception. + /// The elapsed execution time before failure. + public async Task HandleKnownExceptionAsync( + MutationExecutionContext executionContext, + MutationException exception, + TimeSpan duration) + { + await NotifyFailureAsync( + executionContext, + exception, + executionContext.CancellationToken).ConfigureAwait(false); + + await AuditExceptionAsync( + executionContext.Mutation, + exception, + executionContext.ExecutionId, + duration).ConfigureAwait(false); + } + + /// + /// Processes an unexpected exception and converts it into runtime level mutation exception. + /// + /// The state type handled by the mutation. + /// The shared execution context for the failed mutation. + /// The unexpected exception. + /// The elapsed execution time before failure. + /// The wrapped runtime exception to rethrow. + public async Task HandleUnexpectedExceptionAsync( + MutationExecutionContext executionContext, + Exception exception, + TimeSpan duration) + { + await NotifyFailureAsync( + executionContext, + exception, + executionContext.CancellationToken).ConfigureAwait(false); + + await AuditExceptionAsync( + executionContext.Mutation, + exception, + executionContext.ExecutionId, + duration).ConfigureAwait(false); + + return new MutationException( + $"Mutation execution failed: {exception.Message}", + exception) + { + ExecutionId = executionContext.ExecutionId + }; + } + + private async Task NotifyFailureAsync( + MutationExecutionContext executionContext, + Exception exception, + CancellationToken cancellationToken) + { + await _interceptorPipeline.OnMutationFailedAsync( + executionContext.Mutation.Intent, + executionContext.Mutation.Context, + executionContext.State!, + exception, + executionContext.ExecutionId, + cancellationToken).ConfigureAwait(false); + } + + private async Task AuditExceptionAsync( + IMutation mutation, + Exception exception, + string executionId, + TimeSpan duration) + { + var entry = MutationAuditEntryFactory.CreateException( + mutation, + exception, + executionId, + duration); + + await _auditor.AuditAsync(entry).ConfigureAwait(false); + } +} diff --git a/src/Runtime/Internal/Execution/MutationExecutionModeRunner.cs b/src/Runtime/Internal/Execution/MutationExecutionModeRunner.cs new file mode 100644 index 0000000..7963684 --- /dev/null +++ b/src/Runtime/Internal/Execution/MutationExecutionModeRunner.cs @@ -0,0 +1,55 @@ +using ModularityKit.Mutator.Abstractions; +using ModularityKit.Mutator.Abstractions.Changes; +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityExecutionContext = ModularityKit.Mutator.Abstractions.Context.ExecutionContext; + +namespace ModularityKit.Mutator.Runtime.Internal.Execution; + +/// +/// Executes mutation behavior according to the current mutation mode. +/// +internal sealed class MutationExecutionModeRunner( + IMutationExecutor executor, + MutationEngineOptions options) +{ + private readonly IMutationExecutor _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + private readonly MutationEngineOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Runs the mutation using simulate, validate, or commit execution semantics. + /// + public Task> ExecuteAsync(MutationExecutionContext executionContext) + { + var executorContext = new ModularityExecutionContext + { + ExecutionId = executionContext.ExecutionId, + Timeout = _options.ExecutionTimeout, + CancellationToken = executionContext.CancellationToken + }; + + return executionContext.Mutation.Context.Mode switch + { + MutationMode.Simulate => Task.FromResult(executionContext.Mutation.Simulate(executionContext.State)), + MutationMode.Validate => Task.FromResult(BuildValidationOnlyResult( + executionContext.Mutation, + executionContext.State)), + _ => _executor.ExecuteAsync( + executionContext.Mutation, + executionContext.State, + executorContext, + executionContext.CancellationToken) + }; + } + + private static MutationResult BuildValidationOnlyResult( + IMutation mutation, + TState state) + { + var validation = mutation.Validate(state); + return validation.IsValid + ? MutationResult.Success(state, ChangeSet.Empty) + : MutationResult.Failure(validation); + } +} diff --git a/src/Runtime/Internal/Execution/MutationExecutionOutcomeProcessor.cs b/src/Runtime/Internal/Execution/MutationExecutionOutcomeProcessor.cs new file mode 100644 index 0000000..de005bd --- /dev/null +++ b/src/Runtime/Internal/Execution/MutationExecutionOutcomeProcessor.cs @@ -0,0 +1,188 @@ +using ModularityKit.Mutator.Abstractions.Audit; +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.History; +using ModularityKit.Mutator.Abstractions.Interception; +using ModularityKit.Mutator.Abstractions.Metrics; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Runtime.Internal.Evaluation; +using ModularityKit.Mutator.Runtime.Diagnostics; + +namespace ModularityKit.Mutator.Runtime.Internal.Execution; + +/// +/// Handles blocked, failed, and completed mutation outcomes after policy evaluation and execution. +/// +internal sealed class MutationExecutionOutcomeProcessor( + IInterceptorPipeline interceptorPipeline, + IMutationAuditor auditor, + IMutationHistoryStore historyStore, + IMetricsCollector metricsCollector) +{ + private readonly IInterceptorPipeline _interceptorPipeline = + interceptorPipeline ?? throw new ArgumentNullException(nameof(interceptorPipeline)); + + private readonly IMutationAuditor _auditor = auditor ?? throw new ArgumentNullException(nameof(auditor)); + private readonly IMutationHistoryStore _historyStore = historyStore ?? throw new ArgumentNullException(nameof(historyStore)); + private readonly IMetricsCollector _metricsCollector = metricsCollector ?? throw new ArgumentNullException(nameof(metricsCollector)); + + /// + /// Handles a policy-blocked mutation result. + /// + public async Task> HandleBlockedPolicyAsync( + MutationExecutionContext executionContext, + PolicyDecision policyDecision) + { + var blockedResult = MutationResult.PolicyBlocked(policyDecision); + + await _interceptorPipeline.OnPolicyBlockedAsync( + executionContext.Mutation.Intent, + executionContext.Mutation.Context, + executionContext.State!, + policyDecision, + executionContext.ExecutionId, + executionContext.CancellationToken).ConfigureAwait(false); + + await AuditFailureAsync( + executionContext.Mutation, + blockedResult, + executionContext.ExecutionId, + executionContext.Stopwatch.Elapsed).ConfigureAwait(false); + + return await FinalizeResultAsync(executionContext, blockedResult).ConfigureAwait(false); + } + + /// + /// Handles a validation-failed mutation result. + /// + public async Task> HandleValidationFailureAsync( + MutationExecutionContext executionContext, + MutationResult validationFailureResult) + { + await AuditFailureAsync( + executionContext.Mutation, + validationFailureResult, + executionContext.ExecutionId, + executionContext.Stopwatch.Elapsed).ConfigureAwait(false); + + return await FinalizeResultAsync(executionContext, validationFailureResult).ConfigureAwait(false); + } + + /// + /// Completes a mutation result after execution and policy modifications. + /// + public async Task> CompleteMutationAsync( + MutationExecutionContext executionContext, + MutationResult mutationResult, + PolicyDecision policyDecision) + { + var totalElapsed = executionContext.Stopwatch.Elapsed; + var finalizedMutationResult = PolicyModificationApplier.Apply(mutationResult, policyDecision.Modifications); + + await _interceptorPipeline.OnAfterMutationAsync( + executionContext.Mutation.Intent, + executionContext.Mutation.Context, + executionContext.State, + finalizedMutationResult.NewState, + finalizedMutationResult.Changes, + executionContext.ExecutionId, + executionContext.CancellationToken).ConfigureAwait(false); + + await AuditSuccessAsync( + executionContext.Mutation, + finalizedMutationResult, + policyDecision, + executionContext.ExecutionId, + totalElapsed).ConfigureAwait(false); + + if (finalizedMutationResult.IsSuccess && executionContext.Mutation.Context.Mode == MutationMode.Commit) + { + await StoreInHistoryAsync( + executionContext.Mutation, + finalizedMutationResult, + executionContext.ExecutionId, + totalElapsed, + executionContext.CancellationToken).ConfigureAwait(false); + } + + return await FinalizeResultAsync(executionContext, finalizedMutationResult).ConfigureAwait(false); + } + + /// + /// Finalizes a runtime result by recording metrics and attaching total execution time. + /// + public async Task> FinalizeResultAsync( + MutationExecutionContext executionContext, + MutationResult result) + { + var totalElapsed = executionContext.Stopwatch.Elapsed; + executionContext.MetricsScope?.RecordStateSize(StateSizeEstimator.Estimate(executionContext.State)); + + if (executionContext.MetricsScope is not null) + { + await _metricsCollector.RecordAsync( + executionContext.ExecutionId, + executionContext.MetricsScope.Build(), + executionContext.CancellationToken).ConfigureAwait(false); + } + + return result with + { + Metrics = result.Metrics with { ExecutionTime = totalElapsed } + }; + } + + private async Task AuditSuccessAsync( + IMutation mutation, + MutationResult result, + PolicyDecision policyDecision, + string executionId, + TimeSpan duration) + { + var entry = MutationAuditEntryFactory.CreateSuccess( + mutation, + result, + policyDecision, + executionId, + duration); + + await _auditor.AuditAsync(entry).ConfigureAwait(false); + } + + private async Task AuditFailureAsync( + IMutation mutation, + MutationResult result, + string executionId, + TimeSpan duration) + { + var entry = MutationAuditEntryFactory.CreateFailure( + mutation, + result, + executionId, + duration); + + await _auditor.AuditAsync(entry).ConfigureAwait(false); + } + + private async Task StoreInHistoryAsync( + IMutation mutation, + MutationResult result, + string executionId, + TimeSpan duration, + CancellationToken cancellationToken) + { + var stateId = MutationAuditEntryFactory.ResolveStateId(mutation.Context); + if (string.IsNullOrEmpty(stateId)) + return; + + var entry = MutationAuditEntryFactory.CreateHistoryEntry( + mutation, + result, + executionId, + stateId, + duration); + + await _historyStore.StoreAsync(entry, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Runtime/Internal/Execution/MutationExecutionPipeline.cs b/src/Runtime/Internal/Execution/MutationExecutionPipeline.cs new file mode 100644 index 0000000..507aad5 --- /dev/null +++ b/src/Runtime/Internal/Execution/MutationExecutionPipeline.cs @@ -0,0 +1,94 @@ +using ModularityKit.Mutator.Abstractions; +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Interception; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Runtime.Internal.Evaluation; + +namespace ModularityKit.Mutator.Runtime.Internal.Execution; + +/// +/// Orchestrates the high level mutation pipeline after concurrency admission succeeds. +/// +internal sealed class MutationExecutionPipeline( + MutationPolicyEvaluator policyEvaluator, + IInterceptorPipeline interceptorPipeline, + MutationExecutionModeRunner modeRunner, + MutationExecutionOutcomeProcessor outcomeProcessor, + MutationEngineOptions options) +{ + private readonly MutationPolicyEvaluator _policyEvaluator = policyEvaluator ?? throw new ArgumentNullException(nameof(policyEvaluator)); + private readonly IInterceptorPipeline _interceptorPipeline = interceptorPipeline ?? throw new ArgumentNullException(nameof(interceptorPipeline)); + private readonly MutationExecutionModeRunner _modeRunner = modeRunner ?? throw new ArgumentNullException(nameof(modeRunner)); + private readonly MutationExecutionOutcomeProcessor _outcomeProcessor = outcomeProcessor ?? throw new ArgumentNullException(nameof(outcomeProcessor)); + private readonly MutationEngineOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Runs policy evaluation, validation, execution, and outcome processing for a mutation execution context. + /// + public async Task> ExecuteAsync(MutationExecutionContext executionContext) + { + await _interceptorPipeline.OnBeforeMutationAsync( + executionContext.Mutation.Intent, + executionContext.Mutation.Context, + executionContext.State!, + executionContext.ExecutionId, + executionContext.CancellationToken).ConfigureAwait(false); + + var policyDecision = await EvaluatePolicyDecisionAsync(executionContext).ConfigureAwait(false); + if (!policyDecision.IsAllowed) + return await _outcomeProcessor + .HandleBlockedPolicyAsync(executionContext, policyDecision) + .ConfigureAwait(false); + + var validationFailureResult = ValidateIfRequired(executionContext); + if (validationFailureResult is not null) + return await _outcomeProcessor + .HandleValidationFailureAsync(executionContext, validationFailureResult) + .ConfigureAwait(false); + + var mutationResult = await _modeRunner.ExecuteAsync(executionContext).ConfigureAwait(false); + return await _outcomeProcessor + .CompleteMutationAsync(executionContext, mutationResult, policyDecision) + .ConfigureAwait(false); + } + + /// + /// Evaluates policies and records the elapsed policy evaluation time in metrics. + /// + private async Task EvaluatePolicyDecisionAsync( + MutationExecutionContext executionContext) + { + var policyEvaluationStart = executionContext.Stopwatch.Elapsed; + var policyDecision = await _policyEvaluator + .EvaluateAsync( + executionContext.Mutation, + executionContext.State, + executionContext.CancellationToken) + .ConfigureAwait(false); + + executionContext.MetricsScope?.RecordPolicyEvaluationTime( + executionContext.Stopwatch.Elapsed - policyEvaluationStart); + + return policyDecision; + } + + /// + /// Validates the mutation when the current mode and engine options require it. + /// + private MutationResult? ValidateIfRequired( + MutationExecutionContext executionContext) + { + if (executionContext.Mutation.Context.Mode == MutationMode.Commit && !_options.AlwaysValidate) + return null; + + var validationStart = executionContext.Stopwatch.Elapsed; + var validation = executionContext.Mutation.Validate(executionContext.State); + executionContext.MetricsScope?.RecordValidationTime( + executionContext.Stopwatch.Elapsed - validationStart); + + return validation.IsValid + ? null + : MutationResult.Failure(validation); + } +} diff --git a/src/Runtime/MutationEngine.cs b/src/Runtime/MutationEngine.cs index 630c99e..42131f0 100644 --- a/src/Runtime/MutationEngine.cs +++ b/src/Runtime/MutationEngine.cs @@ -1,8 +1,6 @@ using System.Diagnostics; using ModularityKit.Mutator.Abstractions; using ModularityKit.Mutator.Abstractions.Audit; -using ModularityKit.Mutator.Abstractions.Changes; -using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Engine; using ModularityKit.Mutator.Abstractions.Exceptions; using ModularityKit.Mutator.Abstractions.History; @@ -10,11 +8,15 @@ using ModularityKit.Mutator.Abstractions.Metrics; using ModularityKit.Mutator.Abstractions.Policies; using ModularityKit.Mutator.Abstractions.Results; -using ModularityKit.Mutator.Runtime.Internal; -using ModularityExecutionContext = ModularityKit.Mutator.Abstractions.Context.ExecutionContext; +using ModularityKit.Mutator.Runtime.Internal.Execution; +using ModularityKit.Mutator.Runtime.Internal.Evaluation; +using ModularityKit.Mutator.Runtime.Diagnostics; namespace ModularityKit.Mutator.Runtime; +/// +/// Coordinates mutation execution by handling admission, failure wrapping, and public runtime APIs. +/// internal sealed class MutationEngine( IMutationExecutor executor, IPolicyRegistry policyRegistry, @@ -25,15 +27,32 @@ internal sealed class MutationEngine( MutationEngineOptions options) : IMutationEngine { - private readonly IMutationExecutor _executor = executor ?? throw new ArgumentNullException(nameof(executor)); private readonly IPolicyRegistry _policyRegistry = policyRegistry ?? throw new ArgumentNullException(nameof(policyRegistry)); private readonly IInterceptorPipeline _interceptorPipeline = interceptorPipeline ?? throw new ArgumentNullException(nameof(interceptorPipeline)); - private readonly IMutationAuditor _auditor = auditor ?? throw new ArgumentNullException(nameof(auditor)); private readonly IMutationHistoryStore _historyStore = historyStore ?? throw new ArgumentNullException(nameof(historyStore)); private readonly IMetricsCollector _metricsCollector = metricsCollector ?? throw new ArgumentNullException(nameof(metricsCollector)); private readonly MutationEngineOptions _options = options ?? throw new ArgumentNullException(nameof(options)); private readonly MutationExecutionConcurrencyGate _concurrencyGate = CreateConcurrencyGate(options); - + private readonly MutationExecutionFailureHandler _failureHandler = new(interceptorPipeline, auditor); + private readonly MutationExecutionPipeline _executionPipeline = + new( + new MutationPolicyEvaluator(policyRegistry, options), + interceptorPipeline, + new MutationExecutionModeRunner(executor, options), + new MutationExecutionOutcomeProcessor(interceptorPipeline, auditor, historyStore, metricsCollector), + options); + + /// + /// Executes a single mutation using the full governance pipeline. + /// + /// The type of the state being mutated. + /// The mutation to execute. + /// The current state. + /// Token used to cancel execution. + /// + /// A containing the execution outcome, + /// produced changes, and resulting state. + /// public async Task> ExecuteAsync( IMutation mutation, TState state, @@ -50,206 +69,73 @@ public async Task> ExecuteAsync( if (_options.EnableDetailedMetrics) metricsScope = _metricsCollector.BeginScope(executionId); + var executionContext = new MutationExecutionContext + { + Mutation = mutation, + State = state, + ExecutionId = executionId, + Stopwatch = stopwatch, + MetricsScope = metricsScope, + CancellationToken = cancellationToken + }; + try { - return await ExecutePipelineAsync( - mutation, - state, - executionId, - stopwatch, - metricsScope, - cancellationToken); + return await ExecutePipelineAsync(executionContext).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } - catch (Exception ex) + catch (MutationException ex) { stopwatch.Stop(); - await _interceptorPipeline.OnMutationFailedAsync( - mutation.Intent, - mutation.Context, - state!, + await _failureHandler.HandleKnownExceptionAsync( + executionContext, ex, - executionId, - cancellationToken); - - await AuditExceptionAsync(mutation, state, ex, executionId, stopwatch.Elapsed); + stopwatch.Elapsed).ConfigureAwait(false); - throw new MutationException( - $"Mutation execution failed: {ex.Message}", - ex) - { - ExecutionId = executionId - }; - } - finally - { - metricsScope?.Dispose(); - } - } - - private async Task> ExecutePipelineAsync( - IMutation mutation, - TState state, - string executionId, - Stopwatch stopwatch, - IMetricsScope? metricsScope, - CancellationToken cancellationToken) - { - await _interceptorPipeline.OnBeforeMutationAsync( - mutation.Intent, - mutation.Context, - state!, - executionId, - cancellationToken); - - var policyEvaluationStart = stopwatch.Elapsed; - var policyDecision = await EvaluatePoliciesAsync(mutation, state, cancellationToken); - metricsScope?.RecordPolicyEvaluationTime(stopwatch.Elapsed - policyEvaluationStart); - - if (!policyDecision.IsAllowed) - { - var policyBlockedResult = MutationResult.PolicyBlocked(policyDecision); - - await _interceptorPipeline.OnPolicyBlockedAsync( - mutation.Intent, - mutation.Context, - state!, - policyDecision, - executionId, - cancellationToken); - - await AuditFailureAsync(mutation, state, policyBlockedResult, executionId, stopwatch.Elapsed); - - return await FinalizeResultAsync( - policyBlockedResult, - state, - executionId, - stopwatch, - metricsScope, - cancellationToken); + throw; } - - if (mutation.Context.Mode != MutationMode.Commit || _options.AlwaysValidate) + catch (Exception ex) { - var validationStart = stopwatch.Elapsed; - var validation = mutation.Validate(state); - metricsScope?.RecordValidationTime(stopwatch.Elapsed - validationStart); - - if (!validation.IsValid) - { - var validationFailureResult = MutationResult.Failure(validation); - await AuditFailureAsync(mutation, state, validationFailureResult, executionId, stopwatch.Elapsed); + stopwatch.Stop(); - return await FinalizeResultAsync( - validationFailureResult, - state, - executionId, - stopwatch, - metricsScope, - cancellationToken); - } + throw await _failureHandler.HandleUnexpectedExceptionAsync( + executionContext, + ex, + stopwatch.Elapsed).ConfigureAwait(false); } - - var mutationResult = await ExecuteByModeAsync(mutation, state, cancellationToken, executionId); - var totalElapsed = stopwatch.Elapsed; - - mutationResult = PolicyModificationApplier.Apply(mutationResult, policyDecision.Modifications); - - await _interceptorPipeline.OnAfterMutationAsync( - mutation.Intent, - mutation.Context, - state, - mutationResult.NewState, - mutationResult.Changes, - executionId, - cancellationToken); - - await AuditSuccessAsync( - mutation, - state, - mutationResult, - policyDecision, - executionId, - totalElapsed); - - if (mutationResult.IsSuccess && mutation.Context.Mode == MutationMode.Commit) + finally { - await StoreInHistoryAsync( - mutation, - mutationResult, - executionId, - totalElapsed, - cancellationToken); + metricsScope?.Dispose(); } - - return await FinalizeResultAsync( - mutationResult, - state, - executionId, - stopwatch, - metricsScope, - cancellationToken); - } - - private async Task> FinalizeResultAsync( - MutationResult result, - TState state, - string executionId, - Stopwatch stopwatch, - IMetricsScope? metricsScope, - CancellationToken cancellationToken) - { - var totalElapsed = stopwatch.Elapsed; - metricsScope?.RecordStateSize(StateSizeEstimator.Estimate(state)); - - if (metricsScope != null) - await _metricsCollector.RecordAsync(executionId, metricsScope.Build(), cancellationToken); - - return result with - { - Metrics = result.Metrics with { ExecutionTime = totalElapsed } - }; - } - - private Task> ExecuteByModeAsync( - IMutation mutation, - TState state, - CancellationToken cancellationToken, - string executionId) - { - var executionContext = new ModularityExecutionContext - { - ExecutionId = executionId, - Timeout = _options.ExecutionTimeout, - CancellationToken = cancellationToken - }; - - return mutation.Context.Mode switch - { - MutationMode.Simulate => Task.FromResult(mutation.Simulate(state)), - MutationMode.Validate => Task.FromResult(BuildValidationOnlyResult(mutation, state)), - _ => _executor.ExecuteAsync( - mutation, - state, - executionContext, - cancellationToken) - }; - } - - private static MutationResult BuildValidationOnlyResult( - IMutation mutation, - TState state) - { - var validation = mutation.Validate(state); - return validation.IsValid - ? MutationResult.Success(state, ChangeSet.Empty) - : MutationResult.Failure(validation); } + /// + /// Delegates the core execution flow to the internal mutation pipeline. + /// + /// The state type handled by the mutation. + /// The shared execution context carrying the mutation, state, and runtime metadata. + /// The produced by the pipeline. + private Task> ExecutePipelineAsync(MutationExecutionContext executionContext) + => _executionPipeline.ExecuteAsync(executionContext); + + /// + /// Executes a batch of mutations as a single logical transaction. + /// + /// The type of the state being mutated. + /// The sequence of mutations to execute. + /// The initial state. + /// Token used to cancel execution. + /// + /// A describing the outcome of the batch execution. + /// + /// + /// Batch execution is ordered and sequential. Each step passes through the same core concurrency + /// controls as a single execution. Fail-fast vs best-effort behavior is controlled by . + /// public async Task> ExecuteBatchAsync( IEnumerable> mutations, TState state, @@ -263,20 +149,65 @@ public async Task> ExecuteBatchAsync( cancellationToken); } + /// + /// Executes a batch of mutations as a single logical transaction. + /// + /// The type of the state being mutated. + /// The initial state. + /// The mutations to execute in order. + /// + /// A describing the outcome of the batch execution. + /// + /// + /// This overload is optimized for call sites that want a compact mutation list without + /// manually allocating an array. + /// public Task> ExecuteBatchAsync( TState state, params IMutation[] mutations) => ExecuteBatchAsync(mutations, state); + /// + /// Registers a global mutation policy. + /// + /// The state type the policy applies to. + /// The policy to register. + /// + /// Global policies participate in evaluation for every compatible mutation + /// and represent the primary governance mechanism. + /// public void RegisterPolicy(IMutationPolicy policy) => _policyRegistry.Register(policy); + /// + /// Registers a global mutation interceptor. + /// + /// The interceptor to register. + /// + /// Interceptors observe and react to mutation lifecycle events but must not + /// directly alter mutation semantics. + /// public void RegisterInterceptor(IMutationInterceptor interceptor) => _interceptorPipeline.Register(interceptor); + /// + /// Retrieves the mutation history for a given state identifier. + /// + /// The identifier of the state. + /// Token used to cancel the operation. + /// + /// A containing all recorded mutations for the state. + /// public async Task GetHistoryAsync(string stateId, CancellationToken cancellationToken = default) => await _historyStore.GetHistoryAsync(stateId, cancellationToken); + /// + /// Retrieves aggregated mutation execution statistics. + /// + /// Token used to cancel the operation. + /// + /// A snapshot representing engine-level metrics. + /// public async Task GetStatisticsAsync( CancellationToken cancellationToken = default) { @@ -296,95 +227,13 @@ public async Task GetStatisticsAsync( }; } - private Task EvaluatePoliciesAsync( - IMutation mutation, - TState state, - CancellationToken cancellationToken) - { - var policies = _policyRegistry.GetPolicies(); - - foreach (var policy in policies.OrderByDescending(p => p.Priority)) - { - var decision = policy.Evaluate(mutation, state); - - if (!decision.IsAllowed || decision.Modifications != null) - return Task.FromResult(decision); - } - - return Task.FromResult(PolicyDecision.Allow()); - } - - private async Task AuditSuccessAsync( - IMutation mutation, - TState state, - MutationResult result, - PolicyDecision policyDecision, - string executionId, - TimeSpan duration) - { - var entry = MutationAuditEntryFactory.CreateSuccess( - mutation, - result, - policyDecision, - executionId, - duration); - - await _auditor.AuditAsync(entry); - } - - private async Task AuditFailureAsync( - IMutation mutation, - TState state, - MutationResult result, - string executionId, - TimeSpan duration) - { - var entry = MutationAuditEntryFactory.CreateFailure( - mutation, - result, - executionId, - duration); - - await _auditor.AuditAsync(entry); - } - - private async Task AuditExceptionAsync( - IMutation mutation, - TState state, - Exception exception, - string executionId, - TimeSpan duration) - { - var entry = MutationAuditEntryFactory.CreateException( - mutation, - exception, - executionId, - duration); - - await _auditor.AuditAsync(entry); - } - - private async Task StoreInHistoryAsync( - IMutation mutation, - MutationResult result, - string executionId, - TimeSpan duration, - CancellationToken cancellationToken) - { - var stateId = MutationAuditEntryFactory.ResolveStateId(mutation.Context); - if (string.IsNullOrEmpty(stateId)) - return; - - var entry = MutationAuditEntryFactory.CreateHistoryEntry( - mutation, - result, - executionId, - stateId, - duration); - - await _historyStore.StoreAsync(entry, cancellationToken); - } - + /// + /// Creates the runtime concurrency gate from configured engine options. + /// + /// The engine options containing the value. + /// A configured . + /// Thrown when is . + /// Thrown when is less than 1. private static MutationExecutionConcurrencyGate CreateConcurrencyGate(MutationEngineOptions options) { ArgumentNullException.ThrowIfNull(options); diff --git a/src/Runtime/MutatorsServiceCollectionExtensions.cs b/src/Runtime/MutatorsServiceCollectionExtensions.cs index a2a4ca3..6f23dc9 100644 --- a/src/Runtime/MutatorsServiceCollectionExtensions.cs +++ b/src/Runtime/MutatorsServiceCollectionExtensions.cs @@ -66,7 +66,8 @@ public static void AddMutators( EnableDetailedMetrics = presetOptions.EnableDetailedMetrics, StopBatchOnFirstFailure = presetOptions.StopBatchOnFirstFailure, MaxConcurrentMutations = presetOptions.MaxConcurrentMutations, - ExecutionTimeout = presetOptions.ExecutionTimeout + ExecutionTimeout = presetOptions.ExecutionTimeout, + PolicyEvaluationTimeout = presetOptions.PolicyEvaluationTimeout } : new MutationEngineOptions();