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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using ModularityKit.Mutator.Abstractions.Effects;

namespace WorkflowApprovals.Contracts;

[SideEffectDataContract("workflow.rejected", 1)]
internal sealed record WorkflowRejectedSideEffectData
{
public required string Rejector { get; init; }

public required int StepCount { get; init; }

public required string State { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using ModularityKit.Mutator.Abstractions.Effects;

namespace WorkflowApprovals.Contracts;

[SideEffectDataContract("workflow.started", 1)]
internal sealed record WorkflowStartedSideEffectData
{
public required string Initiator { get; init; }

public required int StepCount { get; init; }

public required string WorkflowId { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Intent;
using ModularityKit.Mutator.Abstractions.Results;
using WorkflowApprovals.Contracts;
using WorkflowApprovals.State;

namespace WorkflowApprovals.Mutations;
Expand Down Expand Up @@ -48,9 +49,9 @@ public override MutationResult<ApprovalWorkflowState> Apply(ApprovalWorkflowStat
SideEffect.Critical(
type: "WorkflowRejected",
description: "Workflow rejection requires manual follow-up",
data: new
data: new WorkflowRejectedSideEffectData
{
Rejector,
Rejector = Rejector,
StepCount = steps.Count,
State = "Rejected"
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Intent;
using ModularityKit.Mutator.Abstractions.Results;
using WorkflowApprovals.Contracts;
using WorkflowApprovals.State;

namespace WorkflowApprovals.Mutations;
Expand Down Expand Up @@ -54,9 +55,9 @@ public override MutationResult<ApprovalWorkflowState> Apply(ApprovalWorkflowStat
SideEffect.Create(
type: "WorkflowStarted",
description: "Approval workflow started and ready for first review",
data: new
data: new WorkflowStartedSideEffectData
{
Initiator,
Initiator = Initiator,
StepCount = steps.Count,
WorkflowId = newState.WorkflowId
})
Expand Down
7 changes: 4 additions & 3 deletions Examples/Core/WorkflowApprovals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +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(...)`
- emits a `WorkflowStarted` side effect through `SideEffect.Create(...)` with a typed contract payload

### Approve step

Expand All @@ -85,7 +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(...)`
- emits a critical `WorkflowRejected` side effect through `SideEffect.Critical(...)` with a typed contract payload

## Policies

Expand Down Expand Up @@ -135,7 +135,8 @@ 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`
- how typed payload contracts are registered through `SideEffectDataContractRegistry`
- how `Severity`, `RequiresAction`, `DataContractType`, and typed `Data` can be read from `MutationResult.SideEffects`

## What to read first

Expand Down
25 changes: 23 additions & 2 deletions Examples/Core/WorkflowApprovals/Scenarios/SideEffectsScenario.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using ModularityKit.Mutator.Abstractions.Context;
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Engine;
using System.Text.Json;
using WorkflowApprovals.Contracts;
using WorkflowApprovals.Mutations;
using WorkflowApprovals.State;

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

SideEffectDataContractRegistry.Register<WorkflowStartedSideEffectData>();
SideEffectDataContractRegistry.Register<WorkflowRejectedSideEffectData>();

var state = new ApprovalWorkflowState();

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

if (effect.Data is not null)
var roundtrip = JsonSerializer.Deserialize<SideEffect>(JsonSerializer.Serialize(effect));

if (roundtrip?.TryGetData<WorkflowStartedSideEffectData>(out var started) == true)
{
Console.WriteLine(
$" contract={roundtrip.DataContractType}@v{roundtrip.DataContractVersion} | initiator={started!.Initiator} | workflowId={started.WorkflowId}");
continue;
}

if (roundtrip?.TryGetData<WorkflowRejectedSideEffectData>(out var rejected) == true)
{
Console.WriteLine(
$" contract={roundtrip.DataContractType}@v{roundtrip.DataContractVersion} | rejector={rejected!.Rejector} | state={rejected.State}");
continue;
}

if (roundtrip?.Data is not null)
{
Console.WriteLine($" data={effect.Data}");
Console.WriteLine($" data={roundtrip.Data}");
}
}
}
Expand Down
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.Intent;
using ModularityKit.Mutator.Abstractions.Policies;
using ModularityKit.Mutator.Governance.Abstractions.Approval.Model;
Expand Down Expand Up @@ -351,6 +352,16 @@ private static MutationRequest CreateExecutedRequest(
CreatedAt = executedAt.AddMinutes(-15),
UpdatedAt = executedAt,
ExecutedAt = executedAt,
SideEffects =
[
SideEffect.Critical(
type: "QuotaChangeRequiresReview",
description: "Executed quota change requires review",
data: new GovernanceExecutionSideEffectData
{
Ticket = "BILL-22"
})
],
Metadata = new Dictionary<string, object>
{
["ticket"] = "BILL-22"
Expand Down Expand Up @@ -380,4 +391,10 @@ private static MutationRequest CreateExecutedRequest(
}
]
};

[SideEffectDataContract("examples.governance.execution-side-effect", 1)]
private sealed record GovernanceExecutionSideEffectData
{
public required string Ticket { get; init; }
}
}
10 changes: 10 additions & 0 deletions Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ public static async Task Run(IMutationRequestQueryStore queryStore)
}
}));

GovernanceQueriesSampleData.PrintSection("Requests With Actionable Execution Side Effects");
GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery
{
SideEffects = new MutationRequestSideEffectFilter
{
DataContractTypes = new HashSet<string> { "examples.governance.execution-side-effect" },
RequiresAction = true
}
}));

GovernanceQueriesSampleData.PrintSection("Recent Approval Driven Requests");
GovernanceQueriesSampleData.PrintRequests(await queryStore.GetRecentApprovalsAsync(take: 3));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
using Microsoft.Extensions.DependencyInjection;
using ModularityKit.Mutator.Abstractions.Context;
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Intent;
using ModularityKit.Mutator.Abstractions.Policies;
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts;
using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals;
using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions;
using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests;
using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters;
using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions;
using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
Expand Down Expand Up @@ -50,6 +52,16 @@ public static async Task Run()
ApproverIds = new HashSet<string> { "security-lead" }
}));

PrintSection("Requests With Actionable Side Effects");
PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery
{
SideEffects = new MutationRequestSideEffectFilter
{
DataContractTypes = new HashSet<string> { "examples.redis.execution-side-effect" },
RequiresAction = true
}
}));

PrintSection("Recent Execution Outcomes");
PrintDecisions(await queryStore.GetRecentDecisionsAsync(
MutationRequestDecisionQuery.RecentExecutionOutcomes(),
Expand Down Expand Up @@ -152,6 +164,16 @@ private static MutationRequest CreateExecutedRequest(
CreatedAt = executedAt.AddMinutes(-15),
UpdatedAt = executedAt,
ExecutedAt = executedAt,
SideEffects =
[
SideEffect.Critical(
type: "ExecutedRequestRequiresFollowUp",
description: "Executed request still requires manual follow-up",
data: new RedisExecutionSideEffectData
{
Ticket = "BILL-12"
})
],
Decisions =
[
MutationRequestDecision.Lifecycle(
Expand All @@ -178,6 +200,12 @@ private static MutationRequest CreateExecutedRequest(
]
};

[SideEffectDataContract("examples.redis.execution-side-effect", 1)]
private sealed record RedisExecutionSideEffectData
{
public required string Ticket { get; init; }
}

private static void PrintSection(string title)
{
Console.WriteLine();
Expand Down
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.Intent;
using ModularityKit.Mutator.Abstractions.Policies;
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
Expand Down Expand Up @@ -61,7 +62,17 @@ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime()
with
{
CreatedAt = new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero),
UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero)
UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero),
SideEffects =
[
SideEffect.Critical(
type: "WorkflowRejected",
description: "Workflow rejection requires action",
data: new RedisGovernanceSideEffectData
{
Ticket = "INC-42"
})
]
};

var json = RedisMutationRequestSerializer.Serialize(request);
Expand All @@ -75,9 +86,20 @@ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime()
Assert.Equal(BlastRadiusScope.Module, roundtrip.Intent.EstimatedBlastRadius?.Scope);
Assert.Equal("platform", roundtrip.Intent.Metadata["risk-owner"]);
Assert.Equal("security", roundtrip.Metadata["team"]);
Assert.Single(roundtrip.SideEffects);
Assert.Equal("WorkflowRejected", roundtrip.SideEffects[0].Type);
Assert.Equal("redis.governance.side-effect", roundtrip.SideEffects[0].DataContractType);
Assert.True(roundtrip.SideEffects[0].TryGetData<RedisGovernanceSideEffectData>(out var sideEffectData));
Assert.Equal("INC-42", sideEffectData!.Ticket);
Assert.Single(roundtrip.Requirements);
Assert.Single(roundtrip.ApprovalRequirements);
Assert.Equal("security-lead", roundtrip.ApprovalRequirements[0].ApproverId);
Assert.Equal(3, roundtrip.Decisions.Count);
}

[SideEffectDataContract("redis.governance.side-effect", 1)]
private sealed record RedisGovernanceSideEffectData
{
public required string Ticket { get; init; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using ModularityKit.Mutator.Abstractions.Audit;
using ModularityKit.Mutator.Abstractions.Changes;
using ModularityKit.Mutator.Abstractions.Context;
using ModularityKit.Mutator.Abstractions.Effects;
using ModularityKit.Mutator.Abstractions.Engine;
using ModularityKit.Mutator.Abstractions.History;
using ModularityKit.Mutator.Abstractions.Intent;
Expand Down Expand Up @@ -77,6 +78,9 @@ public async Task ExecuteApproved_executes_request_persists_resulting_version_an
Assert.Equal("v11", result.Request.ResultingStateVersion);
Assert.Equal("v11", result.Request.ExpectedStateVersion);
Assert.NotNull(result.Request.ExecutedAt);
Assert.Single(result.Request.SideEffects);
Assert.Equal("RoleElevated", result.Request.SideEffects[0].Type);
Assert.Equal("governance.execution-effect", result.Request.SideEffects[0].DataContractType);
Assert.Equal(
MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed),
result.Request.Decisions[^1].Type);
Expand Down Expand Up @@ -255,7 +259,17 @@ public MutationResult<RoleState> Apply(RoleState state)

return MutationResult<RoleState>.Success(
newState,
ChangeSet.Single(StateChange.Modified("Role", state.Role, newState.Role)));
ChangeSet.Single(StateChange.Modified("Role", state.Role, newState.Role)),
[
SideEffect.Create(
type: "RoleElevated",
description: "Governed execution elevated the role",
data: new GovernanceExecutionSideEffectData
{
RequestStateId = state.StateId,
NewRole = newState.Role
})
]);
}

public ValidationResult Validate(RoleState state)
Expand All @@ -267,4 +281,12 @@ public ValidationResult Validate(RoleState state)

public MutationResult<RoleState> Simulate(RoleState state) => Apply(state);
}

[SideEffectDataContract("governance.execution-effect", 1)]
private sealed record GovernanceExecutionSideEffectData
{
public required string RequestStateId { get; init; }

public required string NewRole { get; init; }
}
}
Loading
Loading