Skip to content

Commit 72b9217

Browse files
committed
Improve governance DX
1 parent 7ade3ce commit 72b9217

8 files changed

Lines changed: 235 additions & 20 deletions

File tree

Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using ModularityKit.Mutator.Abstractions.Engine;
66
using ModularityKit.Mutator.Abstractions.Intent;
77
using ModularityKit.Mutator.Abstractions.Results;
8+
using ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts;
89
using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
910
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
1011
using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies;
@@ -28,10 +29,8 @@ public static async Task Run()
2829
var resolutionManager = new MutationRequestVersionResolutionManager(store, new MutationRequestVersionResolver());
2930
var executionManager = new GovernanceExecutionManager(store, resolutionManager, engine);
3031

31-
var request = await store.Create(MutationRequestFactory.Approved(
32+
var request = await store.Create(MutationRequestFactory.Approved<FeatureFlagState, EnableFeatureMutation>(
3233
stateId: "tenant-42:feature-flags",
33-
stateType: "FeatureFlagState",
34-
mutationType: nameof(EnableFeatureMutation),
3534
intent: new MutationIntent
3635
{
3736
OperationName = "EnableFeature",
@@ -50,8 +49,6 @@ public static async Task Run()
5049
request.RequestId,
5150
new EnableFeatureMutation(MutationContext.Service("release-orchestrator", "Execute approved rollout"), "v11"),
5251
currentState,
53-
currentState.Version,
54-
resultingStateVersionProvider: state => state.Version,
5552
governanceContext: MutationContext.Service("governance-runtime", "Execute approved governance request"),
5653
strategy: VersionedRequestResolutionStrategy.RejectStale);
5754

@@ -64,7 +61,7 @@ public static async Task Run()
6461
Console.WriteLine($"Reason: {execution.Request.Decisions[^1].Reason}");
6562
}
6663

67-
private sealed record FeatureFlagState(string StateId, bool IsEnabled, string Version);
64+
private sealed record FeatureFlagState(string StateId, bool IsEnabled, string Version) : IVersionedState;
6865

6966
private sealed class EnableFeatureMutation(MutationContext context, string nextVersion)
7067
: MutationBase<FeatureFlagState>(

Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@
77
using ModularityKit.Mutator.Abstractions.History;
88
using ModularityKit.Mutator.Abstractions.Intent;
99
using ModularityKit.Mutator.Abstractions.Results;
10+
using ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts;
1011
using ModularityKit.Mutator.Governance.Abstractions.Execution.Model;
1112
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
13+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
1214
using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions;
1315
using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies;
1416
using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model;
1517
using ModularityKit.Mutator.Governance.Runtime.Execution.Orchestration;
1618
using ModularityKit.Mutator.Governance.Runtime.Resolution.Execution;
1719
using ModularityKit.Mutator.Governance.Runtime.Storage;
18-
using ModularityKit.Mutator.Governance.Tests.TestSupport;
1920
using ModularityKit.Mutator.Runtime;
2021
using Xunit;
2122

@@ -37,7 +38,16 @@ public async Task ExecuteApproved_executes_request_persists_resulting_version_an
3738
var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver());
3839
var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine);
3940

40-
var request = await requestStore.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10"));
41+
var request = await requestStore.Create(MutationRequestFactory.Approved<RoleState, PromoteRoleMutation>(
42+
stateId: "tenant-42:roles",
43+
intent: new MutationIntent
44+
{
45+
OperationName = "GrantRole",
46+
Category = "Security",
47+
Description = "Grant elevated access"
48+
},
49+
context: MutationContext.User("requester", "Requester", "Need access"),
50+
expectedStateVersion: "v10"));
4151
var mutation = new PromoteRoleMutation(
4252
MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"),
4353
nextVersion: "v11");
@@ -47,8 +57,6 @@ public async Task ExecuteApproved_executes_request_persists_resulting_version_an
4757
request.RequestId,
4858
mutation,
4959
state,
50-
currentStateVersion: state.Version,
51-
resultingStateVersionProvider: updated => updated.Version,
5260
governanceContext: MutationContext.Service("governance-runtime", "Execute approved request"),
5361
strategy: VersionedRequestResolutionStrategy.RejectStale);
5462

@@ -84,7 +92,16 @@ public async Task ExecuteApproved_does_not_execute_when_stale_resolution_rejects
8492
var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver());
8593
var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine);
8694

87-
var request = await requestStore.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10"));
95+
var request = await requestStore.Create(MutationRequestFactory.Approved<RoleState, PromoteRoleMutation>(
96+
stateId: "tenant-42:roles",
97+
intent: new MutationIntent
98+
{
99+
OperationName = "GrantRole",
100+
Category = "Security",
101+
Description = "Grant elevated access"
102+
},
103+
context: MutationContext.User("requester", "Requester", "Need access"),
104+
expectedStateVersion: "v10"));
88105
var mutation = new PromoteRoleMutation(
89106
MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"),
90107
nextVersion: "v11");
@@ -94,8 +111,6 @@ public async Task ExecuteApproved_does_not_execute_when_stale_resolution_rejects
94111
request.RequestId,
95112
mutation,
96113
state,
97-
currentStateVersion: state.Version,
98-
resultingStateVersionProvider: updated => updated.Version,
99114
governanceContext: MutationContext.Service("governance-runtime", "Reject stale request"),
100115
strategy: VersionedRequestResolutionStrategy.RejectStale);
101116

@@ -120,7 +135,16 @@ public async Task ExecuteApproved_requires_renewed_approval_before_execution_whe
120135
var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver());
121136
var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine);
122137

123-
var request = await requestStore.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10"));
138+
var request = await requestStore.Create(MutationRequestFactory.Approved<RoleState, PromoteRoleMutation>(
139+
stateId: "tenant-42:roles",
140+
intent: new MutationIntent
141+
{
142+
OperationName = "GrantRole",
143+
Category = "Security",
144+
Description = "Grant elevated access"
145+
},
146+
context: MutationContext.User("requester", "Requester", "Need access"),
147+
expectedStateVersion: "v10"));
124148
var mutation = new PromoteRoleMutation(
125149
MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"),
126150
nextVersion: "v11");
@@ -130,8 +154,6 @@ public async Task ExecuteApproved_requires_renewed_approval_before_execution_whe
130154
request.RequestId,
131155
mutation,
132156
state,
133-
currentStateVersion: state.Version,
134-
resultingStateVersionProvider: updated => updated.Version,
135157
governanceContext: MutationContext.Service("governance-runtime", "Require renewed approval"),
136158
strategy: VersionedRequestResolutionStrategy.RequireRenewedApproval);
137159

@@ -155,7 +177,16 @@ public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_
155177
var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver());
156178
var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine);
157179

158-
var request = await requestStore.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10"));
180+
var request = await requestStore.Create(MutationRequestFactory.Approved<RoleState, PromoteRoleMutation>(
181+
stateId: "tenant-42:roles",
182+
intent: new MutationIntent
183+
{
184+
OperationName = "GrantRole",
185+
Category = "Security",
186+
Description = "Grant elevated access"
187+
},
188+
context: MutationContext.User("requester", "Requester", "Need access"),
189+
expectedStateVersion: "v10"));
159190
var mutation = new PromoteRoleMutation(
160191
MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"),
161192
nextVersion: "v16");
@@ -165,8 +196,6 @@ public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_
165196
request.RequestId,
166197
mutation,
167198
state,
168-
currentStateVersion: state.Version,
169-
resultingStateVersionProvider: updated => updated.Version,
170199
governanceContext: MutationContext.Service("governance-runtime", "Revalidate and execute"),
171200
strategy: VersionedRequestResolutionStrategy.RevalidateOnLatestState);
172201

@@ -185,7 +214,7 @@ public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_
185214
decision => decision.Type == MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RevalidationRequired));
186215
}
187216

188-
private sealed record RoleState(string StateId, string Role, string Version)
217+
private sealed record RoleState(string StateId, string Role, string Version) : IVersionedState
189218
{
190219
public static RoleState Create(string stateId, string role, string version) => new(stateId, role, version);
191220
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using ModularityKit.Mutator.Abstractions.Context;
2+
using ModularityKit.Mutator.Abstractions.Engine;
3+
using ModularityKit.Mutator.Abstractions.Intent;
4+
using ModularityKit.Mutator.Abstractions.Policies;
5+
using ModularityKit.Mutator.Abstractions.Results;
6+
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
7+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
8+
using Xunit;
9+
10+
namespace ModularityKit.Mutator.Governance.Tests.Requests;
11+
12+
public sealed class MutationRequestFactoryTests
13+
{
14+
[Fact]
15+
public void Approved_generic_overload_infers_state_and_mutation_type_names()
16+
{
17+
var request = MutationRequestFactory.Approved<TestState, TestMutation>(
18+
stateId: "tenant-42:test",
19+
intent: new MutationIntent
20+
{
21+
OperationName = "Test",
22+
Category = "Test"
23+
},
24+
context: MutationContext.User("requester", "Requester", "Generic helper"),
25+
expectedStateVersion: "v1");
26+
27+
Assert.Equal(nameof(TestState), request.StateType);
28+
Assert.Equal(nameof(TestMutation), request.MutationType);
29+
Assert.Equal(MutationRequestStatus.Approved, request.Status);
30+
Assert.Equal("v1", request.ExpectedStateVersion);
31+
}
32+
33+
[Fact]
34+
public void PendingApproval_generic_overload_infers_state_and_mutation_type_names()
35+
{
36+
var request = MutationRequestFactory.PendingApproval<TestState, TestMutation>(
37+
stateId: "tenant-42:test",
38+
intent: new MutationIntent
39+
{
40+
OperationName = "Test",
41+
Category = "Test"
42+
},
43+
context: MutationContext.User("requester", "Requester", "Generic helper"),
44+
requirements:
45+
[
46+
PolicyRequirement.Approval("alice", "Approval required")
47+
],
48+
expectedStateVersion: "v1");
49+
50+
Assert.Equal(nameof(TestState), request.StateType);
51+
Assert.Equal(nameof(TestMutation), request.MutationType);
52+
Assert.Equal(MutationRequestStatus.Pending, request.Status);
53+
Assert.Equal(PendingMutationReason.Approval, request.PendingReason);
54+
Assert.Single(request.ApprovalRequirements);
55+
}
56+
57+
private sealed record TestState;
58+
59+
private sealed class TestMutation : IMutation<TestState>
60+
{
61+
public MutationIntent Intent { get; } = new()
62+
{
63+
OperationName = "Test",
64+
Category = "Test"
65+
};
66+
67+
public MutationContext Context { get; } = MutationContext.System("Test");
68+
69+
public MutationResult<TestState> Apply(TestState state) => throw new NotImplementedException();
70+
71+
public ValidationResult Validate(TestState state) => ValidationResult.Success();
72+
73+
public MutationResult<TestState> Simulate(TestState state) => Apply(state);
74+
}
75+
}

src/Governance/Abstractions/Execution/Contracts/IGovernanceExecutionManager.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,16 @@ Task<GovernedExecutionResult<TState>> ExecuteApproved<TState>(
2222
MutationContext governanceContext,
2323
VersionedRequestResolutionStrategy strategy,
2424
CancellationToken cancellationToken = default);
25+
26+
/// <summary>
27+
/// Executes an approved governed mutation request against a versioned state snapshot.
28+
/// </summary>
29+
Task<GovernedExecutionResult<TState>> ExecuteApproved<TState>(
30+
string requestId,
31+
IMutation<TState> mutation,
32+
TState currentState,
33+
MutationContext governanceContext,
34+
VersionedRequestResolutionStrategy strategy,
35+
CancellationToken cancellationToken = default)
36+
where TState : IVersionedState;
2537
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts;
2+
3+
/// <summary>
4+
/// Represents a state snapshot that carries a stable version token.
5+
/// </summary>
6+
public interface IVersionedState
7+
{
8+
/// <summary>
9+
/// Gets the current version token for the state snapshot.
10+
/// </summary>
11+
string Version { get; }
12+
}

src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using ModularityKit.Mutator.Abstractions.Context;
2+
using ModularityKit.Mutator.Abstractions.Engine;
23
using ModularityKit.Mutator.Abstractions.Intent;
34
using ModularityKit.Mutator.Abstractions.Policies;
45
using ModularityKit.Mutator.Governance.Abstractions.Approval.Mapping;
@@ -13,6 +14,31 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
1314
/// </summary>
1415
public static class MutationRequestFactory
1516
{
17+
/// <summary>
18+
/// Creates a request that should enter the pending lifecycle using type inference for the target state and mutation.
19+
/// </summary>
20+
public static MutationRequest Pending<TState, TMutation>(
21+
string stateId,
22+
MutationIntent intent,
23+
MutationContext context,
24+
PendingMutationReason pendingReason,
25+
IReadOnlyList<PolicyRequirement>? requirements = null,
26+
string? expectedStateVersion = null,
27+
DateTimeOffset? expiresAt = null,
28+
IReadOnlyDictionary<string, object>? metadata = null)
29+
where TMutation : IMutation<TState>
30+
=> Pending(
31+
stateId,
32+
typeof(TState).Name,
33+
typeof(TMutation).Name,
34+
intent,
35+
context,
36+
pendingReason,
37+
requirements,
38+
expectedStateVersion,
39+
expiresAt,
40+
metadata);
41+
1642
/// <summary>
1743
/// Creates a request that should enter the pending lifecycle.
1844
/// </summary>
@@ -55,6 +81,29 @@ public static MutationRequest Pending(
5581
};
5682
}
5783

84+
/// <summary>
85+
/// Creates a request that enters pending approval using type inference for the target state and mutation.
86+
/// </summary>
87+
public static MutationRequest PendingApproval<TState, TMutation>(
88+
string stateId,
89+
MutationIntent intent,
90+
MutationContext context,
91+
IReadOnlyList<PolicyRequirement> requirements,
92+
string? expectedStateVersion = null,
93+
DateTimeOffset? expiresAt = null,
94+
IReadOnlyDictionary<string, object>? metadata = null)
95+
where TMutation : IMutation<TState>
96+
=> PendingApproval(
97+
stateId,
98+
typeof(TState).Name,
99+
typeof(TMutation).Name,
100+
intent,
101+
context,
102+
requirements,
103+
expectedStateVersion,
104+
expiresAt,
105+
metadata);
106+
58107
/// <summary>
59108
/// Creates a request that enters pending approval with concrete request-level approval requirements.
60109
/// </summary>
@@ -111,6 +160,25 @@ public static MutationRequest PendingApproval(
111160
};
112161
}
113162

163+
/// <summary>
164+
/// Creates a request that is immediately approved for execution using type inference for the target state and mutation.
165+
/// </summary>
166+
public static MutationRequest Approved<TState, TMutation>(
167+
string stateId,
168+
MutationIntent intent,
169+
MutationContext context,
170+
string? expectedStateVersion = null,
171+
IReadOnlyDictionary<string, object>? metadata = null)
172+
where TMutation : IMutation<TState>
173+
=> Approved(
174+
stateId,
175+
typeof(TState).Name,
176+
typeof(TMutation).Name,
177+
intent,
178+
context,
179+
expectedStateVersion,
180+
metadata);
181+
114182
/// <summary>
115183
/// Creates a request that is immediately approved for execution.
116184
/// </summary>

src/Governance/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ Console.WriteLine($"{persisted.RequestId} -> {persisted.Status}");
4444
- `MutationRequestStatus`
4545
- `PendingMutationReason`
4646

47+
The request factory also has generic overloads such as `Approved<TState, TMutation>()` and
48+
`PendingApproval<TState, TMutation>()` when you already have concrete CLR types and do not want
49+
to repeat `stateType` / `mutationType` strings at the call site.
50+
4751
### Storage
4852

4953
- `IMutationRequestStore`

0 commit comments

Comments
 (0)