Skip to content

Commit 8de3a67

Browse files
committed
Feat: Add side effect support to runtime and examples
Added - Side effect action and timestamp handling in the abstraction layer - Audit entry coverage for mutation side effects - Workflow approvals scenario dedicated to observable side effects - Side effect emission examples for workflow start and workflow rejection Changed - Runtime audit entry creation now carries side effects through the execution pipeline - Mutation result logging now prints emitted side effects with severity and action flags - Rejected workflow scenario now executes an explicit rejection path after blocking policy decision - ADR-006 status now reflects the accepted side effect design Result Side effects are now part of the runtime audit flow and are demonstrated in the runnable workflow example, including critical effects that require follow up action.
1 parent 510dc67 commit 8de3a67

11 files changed

Lines changed: 195 additions & 15 deletions

File tree

Docs/Decision/Adr/ADR_006_Mutation_Side_Effects.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
#adr_006
55

66
## Status
7-
Proposed
7+
Accepted
88

99
## Date
1010
2026-01-22
@@ -34,4 +34,4 @@ Mutations may produce **side effects** that are not direct state changes. Side e
3434

3535
- Separating side effects from the main mutation allows independent logging, auditing, and monitoring.
3636
- Enables defining critical actions that require intervention without modifying state.
37-
- Simplifies integration with observability and security systems.
37+
- Simplifies integration with observability and security systems.

Examples/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using ModularityKit.Mutator.Abstractions.Changes;
22
using ModularityKit.Mutator.Abstractions.Context;
33
using ModularityKit.Mutator.Abstractions.Engine;
4+
using ModularityKit.Mutator.Abstractions.Effects;
45
using ModularityKit.Mutator.Abstractions.Intent;
56
using ModularityKit.Mutator.Abstractions.Results;
67
using WorkflowApprovals.State;
@@ -41,8 +42,21 @@ public MutationResult<ApprovalWorkflowState> Apply(ApprovalWorkflowState state)
4142

4243
var newState = state with { Steps = steps };
4344
var changes = ChangeSet.Single(StateChange.Modified("Workflow", null, "Rejected"));
44-
return MutationResult<ApprovalWorkflowState>.Success(newState, changes);
45+
return MutationResult<ApprovalWorkflowState>.Success(
46+
newState,
47+
changes,
48+
[
49+
SideEffect.Critical(
50+
type: "WorkflowRejected",
51+
description: "Workflow rejection requires manual follow-up",
52+
data: new
53+
{
54+
Rejector,
55+
StepCount = steps.Count,
56+
State = "Rejected"
57+
})
58+
]);
4559
}
4660

4761
public MutationResult<ApprovalWorkflowState> Simulate(ApprovalWorkflowState state) => Apply(state);
48-
}
62+
}

Examples/WorkflowApprovals/Mutations/StartApprovalMutation.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using ModularityKit.Mutator.Abstractions.Changes;
22
using ModularityKit.Mutator.Abstractions.Context;
33
using ModularityKit.Mutator.Abstractions.Engine;
4+
using ModularityKit.Mutator.Abstractions.Effects;
45
using ModularityKit.Mutator.Abstractions.Intent;
56
using ModularityKit.Mutator.Abstractions.Results;
67
using WorkflowApprovals.State;
@@ -45,8 +46,21 @@ public MutationResult<ApprovalWorkflowState> Apply(ApprovalWorkflowState state)
4546
};
4647

4748
var changes = ChangeSet.Single(StateChange.Added("Steps", steps));
48-
return MutationResult<ApprovalWorkflowState>.Success(newState, changes);
49+
return MutationResult<ApprovalWorkflowState>.Success(
50+
newState,
51+
changes,
52+
[
53+
SideEffect.Create(
54+
type: "WorkflowStarted",
55+
description: "Approval workflow started and ready for first review",
56+
data: new
57+
{
58+
Initiator,
59+
StepCount = steps.Count,
60+
WorkflowId = newState.WorkflowId
61+
})
62+
]);
4963
}
5064

5165
public MutationResult<ApprovalWorkflowState> Simulate(ApprovalWorkflowState state) => Apply(state);
52-
}
66+
}

Examples/WorkflowApprovals/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ private static async Task Main()
2323

2424
await Scenarios.HappyPathScenario.Run(engine);
2525
await Scenarios.RejectedScenario.Run(engine);
26+
await Scenarios.SideEffectsScenario.Run(engine);
2627

2728

2829
Console.WriteLine("\n METRICS & STATISTICS");
@@ -37,4 +38,4 @@ private static async Task Main()
3738
Console.WriteLine($" Median execution time: {stats.MedianExecutionTime.TotalMilliseconds:F2} ms");
3839
Console.WriteLine($" P95 execution time: {stats.P95ExecutionTime.TotalMilliseconds:F2} ms");
3940
}
40-
}
41+
}

Examples/WorkflowApprovals/README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ The example includes two scenarios:
1818

1919
- a happy path where the steps are approved in sequence
2020
- a rejection path where one or more approvals are blocked
21+
- a side effects path where workflow start and rejection emit observable side effects
2122

2223
## What this example demonstrates
2324

@@ -26,6 +27,7 @@ The example includes two scenarios:
2627
- policy checks that depend on prior state
2728
- mutation context usage for audit and traceability
2829
- failure handling when step is out of order or unauthorized
30+
- side effect emission for monitoring, alerting, and follow-up workflows
2931

3032
## Project structure
3133

@@ -41,6 +43,7 @@ The example includes two scenarios:
4143
- [`Policies/RequireManagerApprovalPolicy.cs`](Policies/RequireManagerApprovalPolicy.cs)
4244
- [`Scenarios/HappyPathScenario.cs`](Scenarios/HappyPathScenario.cs)
4345
- [`Scenarios/RejectedScenario.cs`](Scenarios/RejectedScenario.cs)
46+
- [`Scenarios/SideEffectsScenario.cs`](Scenarios/SideEffectsScenario.cs)
4447

4548
## How it works
4649

@@ -64,6 +67,7 @@ The sample is intentionally sequential. It shows how stateful process can be adv
6467
- creates workflow steps from names
6568
- initializes a new workflow ID
6669
- emits change entry for the created step list
70+
- emits a `WorkflowStarted` side effect through `SideEffect.Create(...)`
6771

6872
### Approve step
6973

@@ -81,6 +85,7 @@ The sample is intentionally sequential. It shows how stateful process can be adv
8185
- applies rejection to every step
8286
- records the actor who rejected the workflow
8387
- emits workflow level change
88+
- emits a critical `WorkflowRejected` side effect through `SideEffect.Critical(...)`
8489

8590
## Policies
8691

@@ -122,6 +127,16 @@ It shows:
122127
- per step logging
123128
- final workflow state inspection
124129

130+
### Side effects path
131+
132+
[`SideEffectsScenario`](Scenarios/SideEffectsScenario.cs) starts a workflow and then rejects it to show side effects in the result object.
133+
134+
It shows:
135+
136+
- a standard side effect created with `SideEffect.Create(...)`
137+
- a critical side effect created with `SideEffect.Critical(...)`
138+
- how `Severity`, `RequiresAction`, and `Data` can be read from `MutationResult.SideEffects`
139+
125140
## What to read first
126141

127142
1. [`State/ApprovalWorkflowState.cs`](State/ApprovalWorkflowState.cs)
@@ -131,7 +146,7 @@ It shows:
131146
5. [`Mutations/ApproveStepMutation.cs`](Mutations/ApproveStepMutation.cs)
132147
6. [`Policies/EnforceOrderPolicy.cs`](Policies/EnforceOrderPolicy.cs)
133148
7. [`Policies/RequireManagerApprovalPolicy.cs`](Policies/RequireManagerApprovalPolicy.cs)
134-
8. [`Scenarios/HappyPathScenario.cs`](Scenarios/HappyPathScenario.cs)
149+
8. [`Scenarios/SideEffectsScenario.cs`](Scenarios/SideEffectsScenario.cs)
135150

136151
## Run
137152

Examples/WorkflowApprovals/Scenarios/RejectedScenario.cs

Lines changed: 37 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.Engine;
34
using WorkflowApprovals.Mutations;
45
using WorkflowApprovals.State;
@@ -55,6 +56,7 @@ internal static async Task Run(IMutationEngine engine)
5556
state = result.NewState;
5657

5758
var approvers = new[] { "alice", "bob", "carol" };
59+
var workflowRejected = false;
5860

5961
for (var i = 0; i < state.Steps.Count; i++)
6062
{
@@ -73,6 +75,23 @@ internal static async Task Run(IMutationEngine engine)
7375
Console.WriteLine($"✗ Step {i} blocked for {approvers[i]}:");
7476
foreach (var dec in res.PolicyDecisions)
7577
Console.WriteLine($" Policy: {dec.PolicyName}{dec.Reason}");
78+
79+
var rejectContext = MutationContext.User("security.lead", reason: "Reject blocked workflow");
80+
var reject = new RejectWorkflowMutation("security.lead", rejectContext);
81+
var rejectResult = await engine.ExecuteAsync(reject, state);
82+
83+
if (!rejectResult.IsSuccess || rejectResult.NewState == null)
84+
{
85+
Console.WriteLine("✗ Failed to reject workflow after policy block.");
86+
break;
87+
}
88+
89+
state = rejectResult.NewState;
90+
workflowRejected = true;
91+
92+
Console.WriteLine("Workflow rejected after policy block.");
93+
PrintSideEffects("Reject workflow", rejectResult.SideEffects);
94+
break;
7695
}
7796
}
7897

@@ -82,5 +101,22 @@ internal static async Task Run(IMutationEngine engine)
82101
var s = state.Steps[i];
83102
Console.WriteLine($" Step{i}: {s.Status} by {(s.ApprovedBy ?? s.RejectedBy ?? "-")}");
84103
}
104+
105+
if (!workflowRejected)
106+
{
107+
Console.WriteLine("Workflow remained active because no rejection path was triggered.");
108+
}
109+
}
110+
111+
private static void PrintSideEffects(string operation, IReadOnlyList<SideEffect> sideEffects)
112+
{
113+
Console.WriteLine($"{operation} side effects:");
114+
115+
foreach (var effect in sideEffects)
116+
{
117+
Console.WriteLine(
118+
$" {effect.Type} | severity={effect.Severity} | requiresAction={effect.RequiresAction}");
119+
Console.WriteLine($" {effect.Description}");
120+
}
85121
}
86-
}
122+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using ModularityKit.Mutator.Abstractions.Context;
2+
using ModularityKit.Mutator.Abstractions.Effects;
3+
using ModularityKit.Mutator.Abstractions.Engine;
4+
using WorkflowApprovals.Mutations;
5+
using WorkflowApprovals.State;
6+
7+
namespace WorkflowApprovals.Scenarios;
8+
9+
internal static class SideEffectsScenario
10+
{
11+
internal static async Task Run(IMutationEngine engine)
12+
{
13+
Console.WriteLine("\n=== Side Effects Scenario ===");
14+
15+
var state = new ApprovalWorkflowState();
16+
17+
var startContext = MutationContext.System("Start side effect demo", correlationId: "workflow-side-effects");
18+
var start = new StartApprovalMutation("initiator", ["SecurityReview", "FinanceReview"], startContext);
19+
var startResult = await engine.ExecuteAsync(start, state);
20+
21+
if (!startResult.IsSuccess || startResult.NewState == null)
22+
{
23+
Console.WriteLine("✗ Failed to start workflow.");
24+
return;
25+
}
26+
27+
PrintSideEffects("Start workflow", startResult.SideEffects);
28+
29+
state = startResult.NewState;
30+
31+
var rejectContext = MutationContext.User("security.lead", reason: "Reject risky request");
32+
var reject = new RejectWorkflowMutation("security.lead", rejectContext);
33+
var rejectResult = await engine.ExecuteAsync(reject, state);
34+
35+
if (!rejectResult.IsSuccess || rejectResult.NewState == null)
36+
{
37+
Console.WriteLine("✗ Failed to reject workflow.");
38+
return;
39+
}
40+
41+
PrintSideEffects("Reject workflow", rejectResult.SideEffects);
42+
}
43+
44+
private static void PrintSideEffects(string operation, IReadOnlyList<SideEffect> sideEffects)
45+
{
46+
Console.WriteLine($"{operation} side effects:");
47+
48+
foreach (var effect in sideEffects)
49+
{
50+
Console.WriteLine(
51+
$" {effect.Type} | severity={effect.Severity} | requiresAction={effect.RequiresAction}");
52+
Console.WriteLine($" {effect.Description}");
53+
54+
if (effect.Data is not null)
55+
{
56+
Console.WriteLine($" data={effect.Data}");
57+
}
58+
}
59+
}
60+
}

src/Abstractions/Audit/MutationAuditEntry.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using ModularityKit.Mutator.Abstractions.Changes;
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

@@ -61,6 +62,11 @@ public sealed class MutationAuditEntry
6162
/// </summary>
6263
public IReadOnlyList<PolicyDecision> PolicyDecisions { get; init; } = [];
6364

65+
/// <summary>
66+
/// Side effects produced during the mutation.
67+
/// </summary>
68+
public IReadOnlyList<SideEffect> SideEffects { get; init; } = [];
69+
6470
/// <summary>
6571
/// Timestamp when the mutation started.
6672
/// </summary>

src/Abstractions/Effects/SideEffect.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,25 @@ public sealed class SideEffect
4545
/// <param name="description">Human-readable description.</param>
4646
/// <param name="data">Optional associated data.</param>
4747
/// <param name="severity">Severity level.</param>
48+
/// <param name="requiresAction">
49+
/// Indicates whether the side effect requires explicit follow-up. Critical severity always implies action.
50+
/// </param>
51+
/// <param name="timestamp">Optional timestamp override. Defaults to current UTC time.</param>
4852
public static SideEffect Create(
4953
string type,
5054
string description,
5155
object? data = null,
52-
SideEffectSeverity severity = SideEffectSeverity.Info)
56+
SideEffectSeverity severity = SideEffectSeverity.Info,
57+
bool requiresAction = false,
58+
DateTimeOffset? timestamp = null)
5359
=> new()
5460
{
5561
Type = type,
5662
Description = description,
5763
Data = data,
58-
Severity = severity
64+
Severity = severity,
65+
RequiresAction = requiresAction || severity == SideEffectSeverity.Critical,
66+
Timestamp = timestamp ?? DateTimeOffset.UtcNow
5967
};
6068

6169
/// <summary>
@@ -64,6 +72,17 @@ public static SideEffect Create(
6472
/// <param name="type">The type of the side effect.</param>
6573
/// <param name="description">Human-readable description.</param>
6674
/// <param name="data">Optional associated data.</param>
67-
public static SideEffect Critical(string type, string description, object? data = null)
68-
=> Create(type, description, data, SideEffectSeverity.Critical);
75+
/// <param name="timestamp">Optional timestamp override. Defaults to current UTC time.</param>
76+
public static SideEffect Critical(
77+
string type,
78+
string description,
79+
object? data = null,
80+
DateTimeOffset? timestamp = null)
81+
=> Create(
82+
type,
83+
description,
84+
data,
85+
SideEffectSeverity.Critical,
86+
requiresAction: true,
87+
timestamp: timestamp);
6988
}

src/Runtime/Internal/MutationAuditEntryFactory.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using ModularityKit.Mutator.Abstractions.Changes;
33
using ModularityKit.Mutator.Abstractions.Context;
44
using ModularityKit.Mutator.Abstractions.Engine;
5+
using ModularityKit.Mutator.Abstractions.Effects;
56
using ModularityKit.Mutator.Abstractions.History;
67
using ModularityKit.Mutator.Abstractions.Policies;
78
using ModularityKit.Mutator.Abstractions.Results;
@@ -24,6 +25,7 @@ public static MutationAuditEntry CreateSuccess<TState>(
2425
isSuccess: true,
2526
changes: result.Changes,
2627
policyDecisions: result.PolicyDecisions.Count > 0 ? result.PolicyDecisions : [policyDecision],
28+
sideEffects: result.SideEffects,
2729
sourceIpAddress: mutation.Context.SourceIpAddress,
2830
userAgent: mutation.Context.UserAgent);
2931
}
@@ -41,7 +43,8 @@ public static MutationAuditEntry CreateFailure<TState>(
4143
isSuccess: false,
4244
changes: result.Changes,
4345
errorMessage: string.Join("; ", result.ValidationResult.Errors.Select(e => e.Message)),
44-
policyDecisions: result.PolicyDecisions);
46+
policyDecisions: result.PolicyDecisions,
47+
sideEffects: result.SideEffects);
4548
}
4649

4750
public static MutationAuditEntry CreateException<TState>(
@@ -89,6 +92,7 @@ private static MutationAuditEntry Create<TState>(
8992
ChangeSet? changes = null,
9093
string? errorMessage = null,
9194
IReadOnlyList<PolicyDecision>? policyDecisions = null,
95+
IReadOnlyList<SideEffect>? sideEffects = null,
9296
string? sourceIpAddress = null,
9397
string? userAgent = null)
9498
{
@@ -103,6 +107,7 @@ private static MutationAuditEntry Create<TState>(
103107
IsSuccess = isSuccess,
104108
ErrorMessage = errorMessage,
105109
PolicyDecisions = policyDecisions ?? [],
110+
SideEffects = sideEffects ?? [],
106111
Timestamp = mutation.Context.Timestamp,
107112
Duration = duration,
108113
SourceIpAddress = sourceIpAddress,

0 commit comments

Comments
 (0)