Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Docs/Decision/Adr/ADR_006_Mutation_Side_Effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
#adr_006

## Status
Proposed
Accepted

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

- Separating side effects from the main mutation allows independent logging, auditing, and monitoring.
- Enables defining critical actions that require intervention without modifying state.
- Simplifies integration with observability and security systems.
- Simplifies integration with observability and security systems.
18 changes: 16 additions & 2 deletions Examples/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using ModularityKit.Mutator.Abstractions.Changes;
using ModularityKit.Mutator.Abstractions.Context;
using ModularityKit.Mutator.Abstractions.Engine;
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Intent;
using ModularityKit.Mutator.Abstractions.Results;
using WorkflowApprovals.State;
Expand Down Expand Up @@ -41,8 +42,21 @@ public MutationResult<ApprovalWorkflowState> Apply(ApprovalWorkflowState state)

var newState = state with { Steps = steps };
var changes = ChangeSet.Single(StateChange.Modified("Workflow", null, "Rejected"));
return MutationResult<ApprovalWorkflowState>.Success(newState, changes);
return MutationResult<ApprovalWorkflowState>.Success(
newState,
changes,
[
SideEffect.Critical(
type: "WorkflowRejected",
description: "Workflow rejection requires manual follow-up",
data: new
{
Rejector,
StepCount = steps.Count,
State = "Rejected"
})
]);
}

public MutationResult<ApprovalWorkflowState> Simulate(ApprovalWorkflowState state) => Apply(state);
}
}
18 changes: 16 additions & 2 deletions Examples/WorkflowApprovals/Mutations/StartApprovalMutation.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using ModularityKit.Mutator.Abstractions.Changes;
using ModularityKit.Mutator.Abstractions.Context;
using ModularityKit.Mutator.Abstractions.Engine;
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Intent;
using ModularityKit.Mutator.Abstractions.Results;
using WorkflowApprovals.State;
Expand Down Expand Up @@ -45,8 +46,21 @@ public MutationResult<ApprovalWorkflowState> Apply(ApprovalWorkflowState state)
};

var changes = ChangeSet.Single(StateChange.Added("Steps", steps));
return MutationResult<ApprovalWorkflowState>.Success(newState, changes);
return MutationResult<ApprovalWorkflowState>.Success(
newState,
changes,
[
SideEffect.Create(
type: "WorkflowStarted",
description: "Approval workflow started and ready for first review",
data: new
{
Initiator,
StepCount = steps.Count,
WorkflowId = newState.WorkflowId
})
]);
}

public MutationResult<ApprovalWorkflowState> Simulate(ApprovalWorkflowState state) => Apply(state);
}
}
3 changes: 2 additions & 1 deletion Examples/WorkflowApprovals/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ private static async Task Main()

await Scenarios.HappyPathScenario.Run(engine);
await Scenarios.RejectedScenario.Run(engine);
await Scenarios.SideEffectsScenario.Run(engine);


Console.WriteLine("\n METRICS & STATISTICS");
Expand All @@ -37,4 +38,4 @@ private static async Task Main()
Console.WriteLine($" Median execution time: {stats.MedianExecutionTime.TotalMilliseconds:F2} ms");
Console.WriteLine($" P95 execution time: {stats.P95ExecutionTime.TotalMilliseconds:F2} ms");
}
}
}
17 changes: 16 additions & 1 deletion Examples/WorkflowApprovals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ The example includes two scenarios:

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

## What this example demonstrates

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

## Project structure

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

## How it works

Expand All @@ -64,6 +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(...)`

### Approve step

Expand All @@ -81,6 +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(...)`

## Policies

Expand Down Expand Up @@ -122,6 +127,16 @@ It shows:
- per step logging
- final workflow state inspection

### Side effects path

[`SideEffectsScenario`](Scenarios/SideEffectsScenario.cs) starts a workflow and then rejects it to show side effects in the result object.

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`

## What to read first

1. [`State/ApprovalWorkflowState.cs`](State/ApprovalWorkflowState.cs)
Expand All @@ -131,7 +146,7 @@ It shows:
5. [`Mutations/ApproveStepMutation.cs`](Mutations/ApproveStepMutation.cs)
6. [`Policies/EnforceOrderPolicy.cs`](Policies/EnforceOrderPolicy.cs)
7. [`Policies/RequireManagerApprovalPolicy.cs`](Policies/RequireManagerApprovalPolicy.cs)
8. [`Scenarios/HappyPathScenario.cs`](Scenarios/HappyPathScenario.cs)
8. [`Scenarios/SideEffectsScenario.cs`](Scenarios/SideEffectsScenario.cs)

## Run

Expand Down
38 changes: 37 additions & 1 deletion Examples/WorkflowApprovals/Scenarios/RejectedScenario.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using ModularityKit.Mutator.Abstractions.Context;
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Engine;
using WorkflowApprovals.Mutations;
using WorkflowApprovals.State;
Expand Down Expand Up @@ -55,6 +56,7 @@ internal static async Task Run(IMutationEngine engine)
state = result.NewState;

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

for (var i = 0; i < state.Steps.Count; i++)
{
Expand All @@ -73,6 +75,23 @@ internal static async Task Run(IMutationEngine engine)
Console.WriteLine($"✗ Step {i} blocked for {approvers[i]}:");
foreach (var dec in res.PolicyDecisions)
Console.WriteLine($" Policy: {dec.PolicyName} – {dec.Reason}");

var rejectContext = MutationContext.User("security.lead", reason: "Reject blocked workflow");
var reject = new RejectWorkflowMutation("security.lead", rejectContext);
var rejectResult = await engine.ExecuteAsync(reject, state);

if (!rejectResult.IsSuccess || rejectResult.NewState == null)
{
Console.WriteLine("✗ Failed to reject workflow after policy block.");
break;
}

state = rejectResult.NewState;
workflowRejected = true;

Console.WriteLine("Workflow rejected after policy block.");
PrintSideEffects("Reject workflow", rejectResult.SideEffects);
break;
}
}

Expand All @@ -82,5 +101,22 @@ internal static async Task Run(IMutationEngine engine)
var s = state.Steps[i];
Console.WriteLine($" Step{i}: {s.Status} by {(s.ApprovedBy ?? s.RejectedBy ?? "-")}");
}

if (!workflowRejected)
{
Console.WriteLine("Workflow remained active because no rejection path was triggered.");
}
}

private static void PrintSideEffects(string operation, IReadOnlyList<SideEffect> sideEffects)
{
Console.WriteLine($"{operation} side effects:");

foreach (var effect in sideEffects)
{
Console.WriteLine(
$" {effect.Type} | severity={effect.Severity} | requiresAction={effect.RequiresAction}");
Console.WriteLine($" {effect.Description}");
}
}
}
}
60 changes: 60 additions & 0 deletions Examples/WorkflowApprovals/Scenarios/SideEffectsScenario.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using ModularityKit.Mutator.Abstractions.Context;
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Engine;
using WorkflowApprovals.Mutations;
using WorkflowApprovals.State;

namespace WorkflowApprovals.Scenarios;

internal static class SideEffectsScenario
{
internal static async Task Run(IMutationEngine engine)
{
Console.WriteLine("\n=== Side Effects Scenario ===");

var state = new ApprovalWorkflowState();

var startContext = MutationContext.System("Start side effect demo", correlationId: "workflow-side-effects");
var start = new StartApprovalMutation("initiator", ["SecurityReview", "FinanceReview"], startContext);
var startResult = await engine.ExecuteAsync(start, state);

if (!startResult.IsSuccess || startResult.NewState == null)
{
Console.WriteLine("✗ Failed to start workflow.");
return;
}

PrintSideEffects("Start workflow", startResult.SideEffects);

state = startResult.NewState;

var rejectContext = MutationContext.User("security.lead", reason: "Reject risky request");
var reject = new RejectWorkflowMutation("security.lead", rejectContext);
var rejectResult = await engine.ExecuteAsync(reject, state);

if (!rejectResult.IsSuccess || rejectResult.NewState == null)
{
Console.WriteLine("✗ Failed to reject workflow.");
return;
}

PrintSideEffects("Reject workflow", rejectResult.SideEffects);
}

private static void PrintSideEffects(string operation, IReadOnlyList<SideEffect> sideEffects)
{
Console.WriteLine($"{operation} side effects:");

foreach (var effect in sideEffects)
{
Console.WriteLine(
$" {effect.Type} | severity={effect.Severity} | requiresAction={effect.RequiresAction}");
Console.WriteLine($" {effect.Description}");

if (effect.Data is not null)
{
Console.WriteLine($" data={effect.Data}");
}
}
}
}
6 changes: 6 additions & 0 deletions src/Abstractions/Audit/MutationAuditEntry.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using ModularityKit.Mutator.Abstractions.Changes;
using ModularityKit.Mutator.Abstractions.Context;
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Intent;
using ModularityKit.Mutator.Abstractions.Policies;

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

/// <summary>
/// Side effects produced during the mutation.
/// </summary>
public IReadOnlyList<SideEffect> SideEffects { get; init; } = [];

/// <summary>
/// Timestamp when the mutation started.
/// </summary>
Expand Down
27 changes: 23 additions & 4 deletions src/Abstractions/Effects/SideEffect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,25 @@ public sealed class SideEffect
/// <param name="description">Human-readable description.</param>
/// <param name="data">Optional associated data.</param>
/// <param name="severity">Severity level.</param>
/// <param name="requiresAction">
/// Indicates whether the side effect requires explicit follow-up. Critical severity always implies action.
/// </param>
/// <param name="timestamp">Optional timestamp override. Defaults to current UTC time.</param>
public static SideEffect Create(
string type,
string description,
object? data = null,
SideEffectSeverity severity = SideEffectSeverity.Info)
SideEffectSeverity severity = SideEffectSeverity.Info,
bool requiresAction = false,
DateTimeOffset? timestamp = null)
=> new()
{
Type = type,
Description = description,
Data = data,
Severity = severity
Severity = severity,
RequiresAction = requiresAction || severity == SideEffectSeverity.Critical,
Timestamp = timestamp ?? DateTimeOffset.UtcNow
};

/// <summary>
Expand All @@ -64,6 +72,17 @@ public static SideEffect Create(
/// <param name="type">The type of the side effect.</param>
/// <param name="description">Human-readable description.</param>
/// <param name="data">Optional associated data.</param>
public static SideEffect Critical(string type, string description, object? data = null)
=> Create(type, description, data, SideEffectSeverity.Critical);
/// <param name="timestamp">Optional timestamp override. Defaults to current UTC time.</param>
public static SideEffect Critical(
string type,
string description,
object? data = null,
DateTimeOffset? timestamp = null)
=> Create(
type,
description,
data,
SideEffectSeverity.Critical,
requiresAction: true,
timestamp: timestamp);
}
7 changes: 6 additions & 1 deletion src/Runtime/Internal/MutationAuditEntryFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using ModularityKit.Mutator.Abstractions.Changes;
using ModularityKit.Mutator.Abstractions.Context;
using ModularityKit.Mutator.Abstractions.Engine;
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.History;
using ModularityKit.Mutator.Abstractions.Policies;
using ModularityKit.Mutator.Abstractions.Results;
Expand All @@ -24,6 +25,7 @@ public static MutationAuditEntry CreateSuccess<TState>(
isSuccess: true,
changes: result.Changes,
policyDecisions: result.PolicyDecisions.Count > 0 ? result.PolicyDecisions : [policyDecision],
sideEffects: result.SideEffects,
sourceIpAddress: mutation.Context.SourceIpAddress,
userAgent: mutation.Context.UserAgent);
}
Expand All @@ -41,7 +43,8 @@ public static MutationAuditEntry CreateFailure<TState>(
isSuccess: false,
changes: result.Changes,
errorMessage: string.Join("; ", result.ValidationResult.Errors.Select(e => e.Message)),
policyDecisions: result.PolicyDecisions);
policyDecisions: result.PolicyDecisions,
sideEffects: result.SideEffects);
}

public static MutationAuditEntry CreateException<TState>(
Expand Down Expand Up @@ -89,6 +92,7 @@ private static MutationAuditEntry Create<TState>(
ChangeSet? changes = null,
string? errorMessage = null,
IReadOnlyList<PolicyDecision>? policyDecisions = null,
IReadOnlyList<SideEffect>? sideEffects = null,
string? sourceIpAddress = null,
string? userAgent = null)
{
Expand All @@ -103,6 +107,7 @@ private static MutationAuditEntry Create<TState>(
IsSuccess = isSuccess,
ErrorMessage = errorMessage,
PolicyDecisions = policyDecisions ?? [],
SideEffects = sideEffects ?? [],
Timestamp = mutation.Context.Timestamp,
Duration = duration,
SourceIpAddress = sourceIpAddress,
Expand Down
Loading
Loading