Skip to content

Commit ab6cb8f

Browse files
committed
Feat: Persist governance version resolution outcomes
1 parent c56c889 commit ab6cb8f

12 files changed

Lines changed: 394 additions & 90 deletions
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
using VersionedResolution.Scenarios;
22

3-
GovernanceVersionedResolutionScenario.Run();
3+
await GovernanceVersionedResolutionScenario.Run();

Examples/Governance/VersionedResolution/README.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Governance VersionedResolution
22

3-
This example shows how `MutationRequestVersionResolver` handles requests that were approved against an older state version.
3+
This example shows how `MutationRequestVersionResolver` handles requests that were approved against an older state version and how the persisted runtime path stores the resulting decision.
44

55
It is the direct runnable example for the semantics introduced around `ExpectedStateVersion` and stale request handling.
66

@@ -10,13 +10,15 @@ It is the direct runnable example for the semantics introduced around `ExpectedS
1010
- resolving stale requests with `RejectStale`
1111
- resolving stale requests with `RequireRenewedApproval`
1212
- resolving stale requests with `RevalidateOnLatestState`
13+
- persisting a resolved outcome through `MutationRequestVersionResolutionManager`
1314
- inspecting the resulting lifecycle state and appended decision history
1415

1516
## Key files
1617

1718
- [`Program.cs`](Program.cs)
1819
- [`Scenarios/GovernanceVersionedResolutionScenario.cs`](Scenarios/GovernanceVersionedResolutionScenario.cs)
19-
- [`src/Governance/Runtime/MutationRequestVersionResolver.cs`](../../../src/Governance/Runtime/MutationRequestVersionResolver.cs)
20+
- [`src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs`](../../../src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs)
21+
- [`src/Governance/Runtime/Resolution/MutationRequestVersionResolutionManager.cs`](../../../src/Governance/Runtime/Resolution/MutationRequestVersionResolutionManager.cs)
2022
- [`src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs`](../../../src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs)
2123
- [`src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs`](../../../src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs)
2224

@@ -26,12 +28,45 @@ It is the direct runnable example for the semantics introduced around `ExpectedS
2628
dotnet run --project Examples/Governance/VersionedResolution/VersionedResolution.csproj
2729
```
2830

31+
## Example usage
32+
33+
```csharp
34+
var store = new InMemoryMutationRequestStore();
35+
var resolver = new MutationRequestVersionResolver();
36+
var manager = new MutationRequestVersionResolutionManager(store, resolver);
37+
38+
var request = await store.Create(
39+
MutationRequest.Approved(
40+
stateId: "tenant-42:roles",
41+
stateType: "IamRoleState",
42+
mutationType: "GrantRoleMutation",
43+
intent: new MutationIntent
44+
{
45+
OperationName = "GrantRole",
46+
Category = "Security",
47+
Description = "Grant elevated role to tenant operator"
48+
},
49+
context: MutationContext.User("requester-1", "Requester One", "Need elevated access for incident"),
50+
expectedStateVersion: "v10"));
51+
52+
var resolution = await manager.ResolveAndStore(
53+
request.RequestId,
54+
currentStateVersion: "v15",
55+
resolutionContext: MutationContext.User("approver-5", "Approver Five", "Persist resolved request"),
56+
strategy: VersionedRequestResolutionStrategy.RejectStale);
57+
58+
Console.WriteLine(resolution.Outcome);
59+
Console.WriteLine(resolution.Request.Status);
60+
Console.WriteLine(resolution.Request.Decisions[^1].Type);
61+
```
62+
2963
## Expected output
3064

31-
The sample prints one block per resolution strategy and shows:
65+
The sample prints one block per resolution strategy and one persisted-resolution block. It shows:
3266

3367
- selected outcome
3468
- whether the request was stale
3569
- resulting request status
3670
- updated expected version
3771
- last decision recorded during resolution
72+
- persisted request revision for the runtime path

Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
using ModularityKit.Mutator.Governance.Abstractions.Requests;
44
using ModularityKit.Mutator.Governance.Abstractions.Resolution;
55
using ModularityKit.Mutator.Governance.Runtime.Resolution;
6+
using ModularityKit.Mutator.Governance.Runtime.Storage;
67

78
namespace VersionedResolution.Scenarios;
89

910
internal static class GovernanceVersionedResolutionScenario
1011
{
11-
public static void Run()
12+
public static async Task Run()
1213
{
1314
var resolver = new MutationRequestVersionResolver();
15+
var store = new InMemoryMutationRequestStore();
16+
var manager = new MutationRequestVersionResolutionManager(store, resolver);
1417

1518
PrintSection("Current Version Matches Expected Version");
1619
PrintResolution(
@@ -43,6 +46,17 @@ public static void Run()
4346
currentStateVersion: "v15",
4447
resolutionContext: MutationContext.User("approver-4", "Approver Four", "Revalidate on the latest state"),
4548
strategy: VersionedRequestResolutionStrategy.RevalidateOnLatestState));
49+
50+
PrintSection("Persisted Resolution Path");
51+
var persistedRequest = await store.Create(CreateApprovedRequest("v10"));
52+
var persistedResolution = await manager.ResolveAndStore(
53+
persistedRequest.RequestId,
54+
currentStateVersion: "v15",
55+
resolutionContext: MutationContext.User("approver-5", "Approver Five", "Persist resolved request"),
56+
strategy: VersionedRequestResolutionStrategy.RejectStale);
57+
58+
PrintResolution(persistedResolution);
59+
Console.WriteLine($"Persisted revision: {persistedResolution.Request.Revision}");
4660
}
4761

4862
private static MutationRequest CreateApprovedRequest(string expectedStateVersion)

Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using ModularityKit.Mutator.Abstractions.Context;
2+
using ModularityKit.Mutator.Governance.Abstractions.Exceptions;
23
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle;
4+
using ModularityKit.Mutator.Governance.Abstractions.Requests;
35
using ModularityKit.Mutator.Governance.Abstractions.Resolution;
46
using ModularityKit.Mutator.Governance.Runtime.Resolution;
57
using ModularityKit.Mutator.Governance.Runtime.Storage;
@@ -33,4 +35,45 @@ public async Task Resolve_does_not_persist_decision_history_unless_caller_saves_
3335
Assert.Equal(MutationRequestStatus.Approved, loaded.Status);
3436
Assert.Equal(MutationRequestStatus.Rejected, resolution.Request.Status);
3537
}
38+
39+
[Fact]
40+
public async Task ResolveAndStore_persists_decision_history_and_state()
41+
{
42+
var store = new InMemoryMutationRequestStore();
43+
var resolver = new MutationRequestVersionResolver();
44+
var manager = new MutationRequestVersionResolutionManager(store, resolver);
45+
var request = await store.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10"));
46+
47+
var resolution = await manager.ResolveAndStore(
48+
request.RequestId,
49+
currentStateVersion: "v15",
50+
resolutionContext: MutationContext.User("approver", "Approver", "Resolve request"),
51+
strategy: VersionedRequestResolutionStrategy.RejectStale);
52+
53+
var loaded = await store.Get(request.RequestId);
54+
55+
Assert.NotNull(loaded);
56+
Assert.Equal(3, loaded.Decisions.Count);
57+
Assert.Equal(MutationRequestDecisionType.RejectedAsStale, loaded.Decisions[^1].Type);
58+
Assert.Equal(MutationRequestStatus.Rejected, loaded.Status);
59+
Assert.Equal(1, loaded.Revision);
60+
Assert.Equal(loaded, resolution.Request);
61+
}
62+
63+
[Fact]
64+
public async Task ResolveAndStore_throws_not_found_for_missing_request()
65+
{
66+
var store = new InMemoryMutationRequestStore();
67+
var resolver = new MutationRequestVersionResolver();
68+
var manager = new MutationRequestVersionResolutionManager(store, resolver);
69+
70+
var exception = await Assert.ThrowsAsync<MutationRequestNotFoundException>(() =>
71+
manager.ResolveAndStore(
72+
"missing-request",
73+
currentStateVersion: "v15",
74+
resolutionContext: MutationContext.User("approver", "Approver", "Resolve request"),
75+
strategy: VersionedRequestResolutionStrategy.RejectStale));
76+
77+
Assert.Equal("missing-request", exception.RequestId);
78+
}
3679
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using ModularityKit.Mutator.Abstractions.Context;
2+
3+
namespace ModularityKit.Mutator.Governance.Abstractions.Resolution;
4+
5+
/// <summary>
6+
/// Persists version-aware resolution outcomes for governed mutation requests.
7+
/// </summary>
8+
public interface IMutationRequestVersionResolutionManager
9+
{
10+
/// <summary>
11+
/// Resolves a persisted request against the current state version and stores the resulting request state and decision history.
12+
/// </summary>
13+
Task<MutationRequestVersionResolution> ResolveAndStore(
14+
string requestId,
15+
string currentStateVersion,
16+
MutationContext resolutionContext,
17+
VersionedRequestResolutionStrategy strategy,
18+
CancellationToken cancellationToken = default);
19+
}

src/Governance/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Key types:
3535
- `IMutationRequestStore`
3636
- `IMutationRequestLifecycleManager`
3737
- `IMutationRequestVersionResolver`
38+
- `IMutationRequestVersionResolutionManager`
3839
- `MutationRequestVersionResolution`
3940
- `MutationRequestVersionResolutionOutcome`
4041
- `VersionedRequestResolutionStrategy`
@@ -50,6 +51,7 @@ The initial runtime layer currently provides:
5051
- `Runtime/Storage/InMemoryMutationRequestStore`
5152
- `Runtime/Lifecycle/MutationRequestLifecycleManager`
5253
- `Runtime/Resolution/MutationRequestVersionResolver`
54+
- `Runtime/Resolution/MutationRequestVersionResolutionManager`
5355

5456
This keeps the first version small while leaving room for later persistence providers such as Entity Framework Core or PostgreSQL-backed governance stores.
5557

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace ModularityKit.Mutator.Governance.Runtime.Resolution;
2+
3+
/// <summary>
4+
/// Describes how a governed request version compares to the currently observed state version.
5+
/// </summary>
6+
internal sealed record MutationRequestVersionEvaluation
7+
{
8+
/// <summary>
9+
/// Expected version captured on the request before resolution.
10+
/// </summary>
11+
public string? ExpectedStateVersion { get; init; }
12+
13+
/// <summary>
14+
/// Current version observed by the runtime during resolution.
15+
/// </summary>
16+
public string CurrentStateVersion { get; init; } = string.Empty;
17+
18+
/// <summary>
19+
/// Indicates whether the expected and current versions differ.
20+
/// </summary>
21+
public bool IsStale { get; init; }
22+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using ModularityKit.Mutator.Governance.Abstractions.Requests;
2+
3+
namespace ModularityKit.Mutator.Governance.Runtime.Resolution;
4+
5+
/// <summary>
6+
/// Evaluates whether a governed request still matches the currently observed state version.
7+
/// </summary>
8+
internal static class MutationRequestVersionEvaluator
9+
{
10+
/// <summary>
11+
/// Compares the request expected version with the current state version and returns a normalized evaluation model.
12+
/// </summary>
13+
public static MutationRequestVersionEvaluation Evaluate(
14+
MutationRequest request,
15+
string currentStateVersion)
16+
{
17+
ArgumentNullException.ThrowIfNull(request);
18+
19+
if (string.IsNullOrWhiteSpace(currentStateVersion))
20+
throw new ArgumentException("Current state version is required.", nameof(currentStateVersion));
21+
22+
var expectedStateVersion = request.ExpectedStateVersion;
23+
var isStale = !string.IsNullOrWhiteSpace(expectedStateVersion)
24+
&& !string.Equals(expectedStateVersion, currentStateVersion, StringComparison.Ordinal);
25+
26+
return new MutationRequestVersionEvaluation
27+
{
28+
ExpectedStateVersion = expectedStateVersion,
29+
CurrentStateVersion = currentStateVersion,
30+
IsStale = isStale
31+
};
32+
}
33+
}

0 commit comments

Comments
 (0)