From 8de3a672aae278dde95d488a8ac61fd63c554551 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Sun, 21 Jun 2026 13:43:28 +0200 Subject: [PATCH] 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. --- .../Adr/ADR_006_Mutation_Side_Effects.md | 4 +- .../Mutations/RejectWorkflowMutation.cs | 18 +++++- .../Mutations/StartApprovalMutation.cs | 18 +++++- Examples/WorkflowApprovals/Program.cs | 3 +- Examples/WorkflowApprovals/README.md | 17 +++++- .../Scenarios/RejectedScenario.cs | 38 +++++++++++- .../Scenarios/SideEffectsScenario.cs | 60 +++++++++++++++++++ src/Abstractions/Audit/MutationAuditEntry.cs | 6 ++ src/Abstractions/Effects/SideEffect.cs | 27 +++++++-- .../Internal/MutationAuditEntryFactory.cs | 7 ++- src/Runtime/Loggers/MutationResultLogger.cs | 12 +++- 11 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 Examples/WorkflowApprovals/Scenarios/SideEffectsScenario.cs 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 +}