Skip to content

Commit bf9a94b

Browse files
authored
Feat: Add async policy evaluation, typed side effects, and runtime namespace refactor (#43)
## Summary Add async policy evaluation support with external governance checks, introduce typed side effect contracts, and refactor the runtime execution namespace structure for better cohesion. ## Added - `IMutationPolicy<TState>.EvaluateAsync` contract with default sync-to-async bridge for backwards compatibility - `PolicyEvaluationTimeoutException` for explicit timeout failure semantics during async policy evaluation - `SideEffect<TData>` typed side effect contract with explicit payload typing beyond `object?` - Runtime test coverage for typed side effect payloads and Redis serialization roundtrips - `Runtime.Diagnostics` namespace containing `MutationAuditEntryFactory` and `StateSizeEstimator` - `Runtime.Internal.Execution` subfolder grouping pipeline orchestration, concurrency, mode running, batch, and failure handling - `Runtime.Internal.Evaluation` subfolder grouping policy evaluation and policy modification application ## Changed - `MutationPolicyEvaluator` now invokes `EvaluateAsync` with a linked `CancellationTokenSource` respecting `PolicyEvaluationTimeout` from engine options - Sync and async policy paths coexist without ambiguous semantics — async path is the primary evaluation contract - `MutationEngine` wired to `Runtime.Diagnostics` namespace and decomposed execution pipeline - Governance persistence, query, and Redis flows handle typed side effect payloads predictably through explicit `SideEffect<TData>` contracts - All `/// <inheritdoc />` usages in `MutationEngine` replaced with explicit XML documentation matching `IMutationEngine` interface - XML documentation added to all runtime components: `MutationBatchExecutor`, `PolicyModificationApplier`, `MutationAuditEntryFactory`, `StateSizeEstimator` ## Result The runtime execution model is now organized by concern. Policy evaluation is fully async with explicit timeout and cancellation behavior. Side effect contracts are typed and survive persistence and downstream integration scenarios. The execution namespace structure matches the folder layout, aligning with `Runtime.Audit`, `Runtime.Metrics`, and `Runtime.Policies` conventions. ## Testing - dotnet build - dotnet test Tests/ModularityKit.Mutator.Tests/ModularityKit.Mutator.Tests.csproj - dotnet test Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj - dotnet test Tests/ModularityKit.Mutator.Governance.Redis.Tests/ModularityKit.Mutator.Governance.Redis.Tests.csproj ## Linked Issues - Closes #23 - Closes #24 ## Checklist - [x] async policy evaluation path is implemented and tested - [x] sync and async policy paths coexist without ambiguous semantics - [x] typed side effect contract is introduced and integrated with governance persistence - [x] runtime namespace structure aligns with folder layout and existing conventions - [x] XML documentation added to all affected runtime components
2 parents 41f747e + c0e0837 commit bf9a94b

62 files changed

Lines changed: 2952 additions & 550 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using ModularityKit.Mutator.Abstractions.Effects;
2+
3+
namespace WorkflowApprovals.Contracts;
4+
5+
[SideEffectDataContract("workflow.rejected", 1)]
6+
internal sealed record WorkflowRejectedSideEffectData
7+
{
8+
public required string Rejector { get; init; }
9+
10+
public required int StepCount { get; init; }
11+
12+
public required string State { get; init; }
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using ModularityKit.Mutator.Abstractions.Effects;
2+
3+
namespace WorkflowApprovals.Contracts;
4+
5+
[SideEffectDataContract("workflow.started", 1)]
6+
internal sealed record WorkflowStartedSideEffectData
7+
{
8+
public required string Initiator { get; init; }
9+
10+
public required int StepCount { get; init; }
11+
12+
public required string WorkflowId { get; init; }
13+
}

Examples/Core/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using ModularityKit.Mutator.Abstractions.Effects;
55
using ModularityKit.Mutator.Abstractions.Intent;
66
using ModularityKit.Mutator.Abstractions.Results;
7+
using WorkflowApprovals.Contracts;
78
using WorkflowApprovals.State;
89

910
namespace WorkflowApprovals.Mutations;
@@ -48,9 +49,9 @@ public override MutationResult<ApprovalWorkflowState> Apply(ApprovalWorkflowStat
4849
SideEffect.Critical(
4950
type: "WorkflowRejected",
5051
description: "Workflow rejection requires manual follow-up",
51-
data: new
52+
data: new WorkflowRejectedSideEffectData
5253
{
53-
Rejector,
54+
Rejector = Rejector,
5455
StepCount = steps.Count,
5556
State = "Rejected"
5657
})

Examples/Core/WorkflowApprovals/Mutations/StartApprovalMutation.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using ModularityKit.Mutator.Abstractions.Effects;
55
using ModularityKit.Mutator.Abstractions.Intent;
66
using ModularityKit.Mutator.Abstractions.Results;
7+
using WorkflowApprovals.Contracts;
78
using WorkflowApprovals.State;
89

910
namespace WorkflowApprovals.Mutations;
@@ -54,9 +55,9 @@ public override MutationResult<ApprovalWorkflowState> Apply(ApprovalWorkflowStat
5455
SideEffect.Create(
5556
type: "WorkflowStarted",
5657
description: "Approval workflow started and ready for first review",
57-
data: new
58+
data: new WorkflowStartedSideEffectData
5859
{
59-
Initiator,
60+
Initiator = Initiator,
6061
StepCount = steps.Count,
6162
WorkflowId = newState.WorkflowId
6263
})

Examples/Core/WorkflowApprovals/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ The sample is intentionally sequential. It shows how stateful process can be adv
6767
- creates workflow steps from names
6868
- initializes a new workflow ID
6969
- emits change entry for the created step list
70-
- emits a `WorkflowStarted` side effect through `SideEffect.Create(...)`
70+
- emits a `WorkflowStarted` side effect through `SideEffect.Create(...)` with a typed contract payload
7171

7272
### Approve step
7373

@@ -85,7 +85,7 @@ The sample is intentionally sequential. It shows how stateful process can be adv
8585
- applies rejection to every step
8686
- records the actor who rejected the workflow
8787
- emits workflow level change
88-
- emits a critical `WorkflowRejected` side effect through `SideEffect.Critical(...)`
88+
- emits a critical `WorkflowRejected` side effect through `SideEffect.Critical(...)` with a typed contract payload
8989

9090
## Policies
9191

@@ -135,7 +135,8 @@ It shows:
135135

136136
- a standard side effect created with `SideEffect.Create(...)`
137137
- a critical side effect created with `SideEffect.Critical(...)`
138-
- how `Severity`, `RequiresAction`, and `Data` can be read from `MutationResult.SideEffects`
138+
- how typed payload contracts are registered through `SideEffectDataContractRegistry`
139+
- how `Severity`, `RequiresAction`, `DataContractType`, and typed `Data` can be read from `MutationResult.SideEffects`
139140

140141
## What to read first
141142

Examples/Core/WorkflowApprovals/Scenarios/SideEffectsScenario.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using ModularityKit.Mutator.Abstractions.Context;
22
using ModularityKit.Mutator.Abstractions.Effects;
33
using ModularityKit.Mutator.Abstractions.Engine;
4+
using System.Text.Json;
5+
using WorkflowApprovals.Contracts;
46
using WorkflowApprovals.Mutations;
57
using WorkflowApprovals.State;
68

@@ -12,6 +14,9 @@ internal static async Task Run(IMutationEngine engine)
1214
{
1315
Console.WriteLine("\n=== Side Effects Scenario ===");
1416

17+
SideEffectDataContractRegistry.Register<WorkflowStartedSideEffectData>();
18+
SideEffectDataContractRegistry.Register<WorkflowRejectedSideEffectData>();
19+
1520
var state = new ApprovalWorkflowState();
1621

1722
var startContext = MutationContext.System("Start side effect demo", correlationId: "workflow-side-effects");
@@ -51,9 +56,25 @@ private static void PrintSideEffects(string operation, IReadOnlyList<SideEffect>
5156
$" {effect.Type} | severity={effect.Severity} | requiresAction={effect.RequiresAction}");
5257
Console.WriteLine($" {effect.Description}");
5358

54-
if (effect.Data is not null)
59+
var roundtrip = JsonSerializer.Deserialize<SideEffect>(JsonSerializer.Serialize(effect));
60+
61+
if (roundtrip?.TryGetData<WorkflowStartedSideEffectData>(out var started) == true)
62+
{
63+
Console.WriteLine(
64+
$" contract={roundtrip.DataContractType}@v{roundtrip.DataContractVersion} | initiator={started!.Initiator} | workflowId={started.WorkflowId}");
65+
continue;
66+
}
67+
68+
if (roundtrip?.TryGetData<WorkflowRejectedSideEffectData>(out var rejected) == true)
69+
{
70+
Console.WriteLine(
71+
$" contract={roundtrip.DataContractType}@v{roundtrip.DataContractVersion} | rejector={rejected!.Rejector} | state={rejected.State}");
72+
continue;
73+
}
74+
75+
if (roundtrip?.Data is not null)
5576
{
56-
Console.WriteLine($" data={effect.Data}");
77+
Console.WriteLine($" data={roundtrip.Data}");
5778
}
5879
}
5980
}

Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using ModularityKit.Mutator.Abstractions.Context;
2+
using ModularityKit.Mutator.Abstractions.Effects;
23
using ModularityKit.Mutator.Abstractions.Intent;
34
using ModularityKit.Mutator.Abstractions.Policies;
45
using ModularityKit.Mutator.Governance.Abstractions.Approval.Model;
@@ -351,6 +352,16 @@ private static MutationRequest CreateExecutedRequest(
351352
CreatedAt = executedAt.AddMinutes(-15),
352353
UpdatedAt = executedAt,
353354
ExecutedAt = executedAt,
355+
SideEffects =
356+
[
357+
SideEffect.Critical(
358+
type: "QuotaChangeRequiresReview",
359+
description: "Executed quota change requires review",
360+
data: new GovernanceExecutionSideEffectData
361+
{
362+
Ticket = "BILL-22"
363+
})
364+
],
354365
Metadata = new Dictionary<string, object>
355366
{
356367
["ticket"] = "BILL-22"
@@ -380,4 +391,10 @@ private static MutationRequest CreateExecutedRequest(
380391
}
381392
]
382393
};
394+
395+
[SideEffectDataContract("examples.governance.execution-side-effect", 1)]
396+
private sealed record GovernanceExecutionSideEffectData
397+
{
398+
public required string Ticket { get; init; }
399+
}
383400
}

Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ public static async Task Run(IMutationRequestQueryStore queryStore)
5555
}
5656
}));
5757

58+
GovernanceQueriesSampleData.PrintSection("Requests With Actionable Execution Side Effects");
59+
GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery
60+
{
61+
SideEffects = new MutationRequestSideEffectFilter
62+
{
63+
DataContractTypes = new HashSet<string> { "examples.governance.execution-side-effect" },
64+
RequiresAction = true
65+
}
66+
}));
67+
5868
GovernanceQueriesSampleData.PrintSection("Recent Approval Driven Requests");
5969
GovernanceQueriesSampleData.PrintRequests(await queryStore.GetRecentApprovalsAsync(take: 3));
6070
}

Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using ModularityKit.Mutator.Abstractions.Context;
3+
using ModularityKit.Mutator.Abstractions.Effects;
34
using ModularityKit.Mutator.Abstractions.Intent;
45
using ModularityKit.Mutator.Abstractions.Policies;
56
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
67
using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts;
78
using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals;
89
using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions;
910
using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests;
11+
using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters;
1012
using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions;
1113
using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
1214
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
@@ -50,6 +52,16 @@ public static async Task Run()
5052
ApproverIds = new HashSet<string> { "security-lead" }
5153
}));
5254

55+
PrintSection("Requests With Actionable Side Effects");
56+
PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery
57+
{
58+
SideEffects = new MutationRequestSideEffectFilter
59+
{
60+
DataContractTypes = new HashSet<string> { "examples.redis.execution-side-effect" },
61+
RequiresAction = true
62+
}
63+
}));
64+
5365
PrintSection("Recent Execution Outcomes");
5466
PrintDecisions(await queryStore.GetRecentDecisionsAsync(
5567
MutationRequestDecisionQuery.RecentExecutionOutcomes(),
@@ -152,6 +164,16 @@ private static MutationRequest CreateExecutedRequest(
152164
CreatedAt = executedAt.AddMinutes(-15),
153165
UpdatedAt = executedAt,
154166
ExecutedAt = executedAt,
167+
SideEffects =
168+
[
169+
SideEffect.Critical(
170+
type: "ExecutedRequestRequiresFollowUp",
171+
description: "Executed request still requires manual follow-up",
172+
data: new RedisExecutionSideEffectData
173+
{
174+
Ticket = "BILL-12"
175+
})
176+
],
155177
Decisions =
156178
[
157179
MutationRequestDecision.Lifecycle(
@@ -178,6 +200,12 @@ private static MutationRequest CreateExecutedRequest(
178200
]
179201
};
180202

203+
[SideEffectDataContract("examples.redis.execution-side-effect", 1)]
204+
private sealed record RedisExecutionSideEffectData
205+
{
206+
public required string Ticket { get; init; }
207+
}
208+
181209
private static void PrintSection(string title)
182210
{
183211
Console.WriteLine();

Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using ModularityKit.Mutator.Abstractions.Context;
2+
using ModularityKit.Mutator.Abstractions.Effects;
23
using ModularityKit.Mutator.Abstractions.Intent;
34
using ModularityKit.Mutator.Abstractions.Policies;
45
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
@@ -61,7 +62,17 @@ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime()
6162
with
6263
{
6364
CreatedAt = new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero),
64-
UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero)
65+
UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero),
66+
SideEffects =
67+
[
68+
SideEffect.Critical(
69+
type: "WorkflowRejected",
70+
description: "Workflow rejection requires action",
71+
data: new RedisGovernanceSideEffectData
72+
{
73+
Ticket = "INC-42"
74+
})
75+
]
6576
};
6677

6778
var json = RedisMutationRequestSerializer.Serialize(request);
@@ -75,9 +86,20 @@ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime()
7586
Assert.Equal(BlastRadiusScope.Module, roundtrip.Intent.EstimatedBlastRadius?.Scope);
7687
Assert.Equal("platform", roundtrip.Intent.Metadata["risk-owner"]);
7788
Assert.Equal("security", roundtrip.Metadata["team"]);
89+
Assert.Single(roundtrip.SideEffects);
90+
Assert.Equal("WorkflowRejected", roundtrip.SideEffects[0].Type);
91+
Assert.Equal("redis.governance.side-effect", roundtrip.SideEffects[0].DataContractType);
92+
Assert.True(roundtrip.SideEffects[0].TryGetData<RedisGovernanceSideEffectData>(out var sideEffectData));
93+
Assert.Equal("INC-42", sideEffectData!.Ticket);
7894
Assert.Single(roundtrip.Requirements);
7995
Assert.Single(roundtrip.ApprovalRequirements);
8096
Assert.Equal("security-lead", roundtrip.ApprovalRequirements[0].ApproverId);
8197
Assert.Equal(3, roundtrip.Decisions.Count);
8298
}
99+
100+
[SideEffectDataContract("redis.governance.side-effect", 1)]
101+
private sealed record RedisGovernanceSideEffectData
102+
{
103+
public required string Ticket { get; init; }
104+
}
83105
}

0 commit comments

Comments
 (0)