diff --git a/Docs/Decision/Adr/ADR_006_Mutation_Side_Effects.md b/Docs/Decision/Adr/ADR_006_Mutation_Side_Effects.md index 5ea2744..4244942 100644 --- a/Docs/Decision/Adr/ADR_006_Mutation_Side_Effects.md +++ b/Docs/Decision/Adr/ADR_006_Mutation_Side_Effects.md @@ -4,7 +4,7 @@ #adr_006 ## Status -Proposed +Accepted ## Date 2026-01-22 @@ -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. \ No newline at end of file +- Simplifies integration with observability and security systems. diff --git a/Examples/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs b/Examples/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs index 09664f4..51aa6e2 100644 --- a/Examples/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs +++ b/Examples/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs @@ -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; @@ -41,8 +42,21 @@ public MutationResult Apply(ApprovalWorkflowState state) var newState = state with { Steps = steps }; var changes = ChangeSet.Single(StateChange.Modified("Workflow", null, "Rejected")); - return MutationResult.Success(newState, changes); + return MutationResult.Success( + newState, + changes, + [ + SideEffect.Critical( + type: "WorkflowRejected", + description: "Workflow rejection requires manual follow-up", + data: new + { + Rejector, + StepCount = steps.Count, + State = "Rejected" + }) + ]); } public MutationResult Simulate(ApprovalWorkflowState state) => Apply(state); -} \ No newline at end of file +} diff --git a/Examples/WorkflowApprovals/Mutations/StartApprovalMutation.cs b/Examples/WorkflowApprovals/Mutations/StartApprovalMutation.cs index 3266ccb..6e5a956 100644 --- a/Examples/WorkflowApprovals/Mutations/StartApprovalMutation.cs +++ b/Examples/WorkflowApprovals/Mutations/StartApprovalMutation.cs @@ -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; @@ -45,8 +46,21 @@ public MutationResult Apply(ApprovalWorkflowState state) }; var changes = ChangeSet.Single(StateChange.Added("Steps", steps)); - return MutationResult.Success(newState, changes); + return MutationResult.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 Simulate(ApprovalWorkflowState state) => Apply(state); -} \ No newline at end of file +} diff --git a/Examples/WorkflowApprovals/Program.cs b/Examples/WorkflowApprovals/Program.cs index 22bd19d..8c9342d 100644 --- a/Examples/WorkflowApprovals/Program.cs +++ b/Examples/WorkflowApprovals/Program.cs @@ -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"); @@ -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"); } -} \ No newline at end of file +} diff --git a/Examples/WorkflowApprovals/README.md b/Examples/WorkflowApprovals/README.md index 91c06c7..8cf0c74 100644 --- a/Examples/WorkflowApprovals/README.md +++ b/Examples/WorkflowApprovals/README.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) @@ -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 diff --git a/Examples/WorkflowApprovals/Scenarios/RejectedScenario.cs b/Examples/WorkflowApprovals/Scenarios/RejectedScenario.cs index d43b88f..1dbb7b1 100644 --- a/Examples/WorkflowApprovals/Scenarios/RejectedScenario.cs +++ b/Examples/WorkflowApprovals/Scenarios/RejectedScenario.cs @@ -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; @@ -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++) { @@ -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; } } @@ -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 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}"); + } } -} \ No newline at end of file +} diff --git a/Examples/WorkflowApprovals/Scenarios/SideEffectsScenario.cs b/Examples/WorkflowApprovals/Scenarios/SideEffectsScenario.cs new file mode 100644 index 0000000..1e532ca --- /dev/null +++ b/Examples/WorkflowApprovals/Scenarios/SideEffectsScenario.cs @@ -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 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}"); + } + } + } +} diff --git a/src/Abstractions/Audit/MutationAuditEntry.cs b/src/Abstractions/Audit/MutationAuditEntry.cs index 6351247..f61762d 100644 --- a/src/Abstractions/Audit/MutationAuditEntry.cs +++ b/src/Abstractions/Audit/MutationAuditEntry.cs @@ -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; @@ -61,6 +62,11 @@ public sealed class MutationAuditEntry /// public IReadOnlyList PolicyDecisions { get; init; } = []; + /// + /// Side effects produced during the mutation. + /// + public IReadOnlyList SideEffects { get; init; } = []; + /// /// Timestamp when the mutation started. /// diff --git a/src/Abstractions/Effects/SideEffect.cs b/src/Abstractions/Effects/SideEffect.cs index 6a97006..9025814 100644 --- a/src/Abstractions/Effects/SideEffect.cs +++ b/src/Abstractions/Effects/SideEffect.cs @@ -45,17 +45,25 @@ public sealed class SideEffect /// Human-readable description. /// Optional associated data. /// 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, 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 }; /// @@ -64,6 +72,17 @@ public static SideEffect Create( /// The type of the side effect. /// Human-readable description. /// Optional associated data. - public static SideEffect Critical(string type, string description, object? data = null) - => Create(type, description, data, SideEffectSeverity.Critical); + /// Optional timestamp override. Defaults to current UTC time. + public static SideEffect Critical( + string type, + string description, + object? data = null, + DateTimeOffset? timestamp = null) + => Create( + type, + description, + data, + SideEffectSeverity.Critical, + requiresAction: true, + timestamp: timestamp); } diff --git a/src/Runtime/Internal/MutationAuditEntryFactory.cs b/src/Runtime/Internal/MutationAuditEntryFactory.cs index 9d412ac..944097a 100644 --- a/src/Runtime/Internal/MutationAuditEntryFactory.cs +++ b/src/Runtime/Internal/MutationAuditEntryFactory.cs @@ -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; @@ -24,6 +25,7 @@ public static MutationAuditEntry CreateSuccess( isSuccess: true, changes: result.Changes, policyDecisions: result.PolicyDecisions.Count > 0 ? result.PolicyDecisions : [policyDecision], + sideEffects: result.SideEffects, sourceIpAddress: mutation.Context.SourceIpAddress, userAgent: mutation.Context.UserAgent); } @@ -41,7 +43,8 @@ public static MutationAuditEntry CreateFailure( 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( @@ -89,6 +92,7 @@ private static MutationAuditEntry Create( ChangeSet? changes = null, string? errorMessage = null, IReadOnlyList? policyDecisions = null, + IReadOnlyList? sideEffects = null, string? sourceIpAddress = null, string? userAgent = null) { @@ -103,6 +107,7 @@ private static MutationAuditEntry Create( IsSuccess = isSuccess, ErrorMessage = errorMessage, PolicyDecisions = policyDecisions ?? [], + SideEffects = sideEffects ?? [], Timestamp = mutation.Context.Timestamp, Duration = duration, SourceIpAddress = sourceIpAddress, diff --git a/src/Runtime/Loggers/MutationResultLogger.cs b/src/Runtime/Loggers/MutationResultLogger.cs index 1486cdb..359c91e 100644 --- a/src/Runtime/Loggers/MutationResultLogger.cs +++ b/src/Runtime/Loggers/MutationResultLogger.cs @@ -38,6 +38,16 @@ public static void LogBatch(IEnumerable> results) Console.WriteLine($" ✓ Success, changes: {result.Changes.Count}"); foreach (var change in result.Changes.Changes) Console.WriteLine($" - {change.Path}: {change.OldValue} -> {change.NewValue}"); + + if (result.SideEffects.Count == 0) continue; + + Console.WriteLine(" Side effects:"); + foreach (var effect in result.SideEffects) + { + Console.WriteLine( + $" - {effect.Type}: {effect.Description} " + + $"(severity={effect.Severity}, requiresAction={effect.RequiresAction})"); + } } } -} \ No newline at end of file +}