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
2 changes: 1 addition & 1 deletion Examples/Governance/VersionedResolution/Program.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
using VersionedResolution.Scenarios;

GovernanceVersionedResolutionScenario.Run();
await GovernanceVersionedResolutionScenario.Run();
41 changes: 38 additions & 3 deletions Examples/Governance/VersionedResolution/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Governance VersionedResolution

This example shows how `MutationRequestVersionResolver` handles requests that were approved against an older state version.
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.

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

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

## Key files

- [`Program.cs`](Program.cs)
- [`Scenarios/GovernanceVersionedResolutionScenario.cs`](Scenarios/GovernanceVersionedResolutionScenario.cs)
- [`src/Governance/Runtime/MutationRequestVersionResolver.cs`](../../../src/Governance/Runtime/MutationRequestVersionResolver.cs)
- [`src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs`](../../../src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs)
- [`src/Governance/Runtime/Resolution/MutationRequestVersionResolutionManager.cs`](../../../src/Governance/Runtime/Resolution/MutationRequestVersionResolutionManager.cs)
- [`src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs`](../../../src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs)
- [`src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs`](../../../src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs)

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

## Example usage

```csharp
var store = new InMemoryMutationRequestStore();
var resolver = new MutationRequestVersionResolver();
var manager = new MutationRequestVersionResolutionManager(store, resolver);

var request = await store.Create(
MutationRequest.Approved(
stateId: "tenant-42:roles",
stateType: "IamRoleState",
mutationType: "GrantRoleMutation",
intent: new MutationIntent
{
OperationName = "GrantRole",
Category = "Security",
Description = "Grant elevated role to tenant operator"
},
context: MutationContext.User("requester-1", "Requester One", "Need elevated access for incident"),
expectedStateVersion: "v10"));

var resolution = await manager.ResolveAndStore(
request.RequestId,
currentStateVersion: "v15",
resolutionContext: MutationContext.User("approver-5", "Approver Five", "Persist resolved request"),
strategy: VersionedRequestResolutionStrategy.RejectStale);

Console.WriteLine(resolution.Outcome);
Console.WriteLine(resolution.Request.Status);
Console.WriteLine(resolution.Request.Decisions[^1].Type);
```

## Expected output

The sample prints one block per resolution strategy and shows:
The sample prints one block per resolution strategy and one persisted-resolution block. It shows:

- selected outcome
- whether the request was stale
- resulting request status
- updated expected version
- last decision recorded during resolution
- persisted request revision for the runtime path
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
using ModularityKit.Mutator.Governance.Abstractions.Requests;
using ModularityKit.Mutator.Governance.Abstractions.Resolution;
using ModularityKit.Mutator.Governance.Runtime.Resolution;
using ModularityKit.Mutator.Governance.Runtime.Storage;

namespace VersionedResolution.Scenarios;

internal static class GovernanceVersionedResolutionScenario
{
public static void Run()
public static async Task Run()
{
var resolver = new MutationRequestVersionResolver();
var store = new InMemoryMutationRequestStore();
var manager = new MutationRequestVersionResolutionManager(store, resolver);

PrintSection("Current Version Matches Expected Version");
PrintResolution(
Expand Down Expand Up @@ -43,6 +46,17 @@ public static void Run()
currentStateVersion: "v15",
resolutionContext: MutationContext.User("approver-4", "Approver Four", "Revalidate on the latest state"),
strategy: VersionedRequestResolutionStrategy.RevalidateOnLatestState));

PrintSection("Persisted Resolution Path");
var persistedRequest = await store.Create(CreateApprovedRequest("v10"));
var persistedResolution = await manager.ResolveAndStore(
persistedRequest.RequestId,
currentStateVersion: "v15",
resolutionContext: MutationContext.User("approver-5", "Approver Five", "Persist resolved request"),
strategy: VersionedRequestResolutionStrategy.RejectStale);

PrintResolution(persistedResolution);
Console.WriteLine($"Persisted revision: {persistedResolution.Request.Revision}");
}

private static MutationRequest CreateApprovedRequest(string expectedStateVersion)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using ModularityKit.Mutator.Abstractions.Context;
using ModularityKit.Mutator.Governance.Abstractions.Exceptions;
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle;
using ModularityKit.Mutator.Governance.Abstractions.Requests;
using ModularityKit.Mutator.Governance.Abstractions.Resolution;
using ModularityKit.Mutator.Governance.Runtime.Resolution;
using ModularityKit.Mutator.Governance.Runtime.Storage;
Expand Down Expand Up @@ -33,4 +35,45 @@ public async Task Resolve_does_not_persist_decision_history_unless_caller_saves_
Assert.Equal(MutationRequestStatus.Approved, loaded.Status);
Assert.Equal(MutationRequestStatus.Rejected, resolution.Request.Status);
}

[Fact]
public async Task ResolveAndStore_persists_decision_history_and_state()
{
var store = new InMemoryMutationRequestStore();
var resolver = new MutationRequestVersionResolver();
var manager = new MutationRequestVersionResolutionManager(store, resolver);
var request = await store.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10"));

var resolution = await manager.ResolveAndStore(
request.RequestId,
currentStateVersion: "v15",
resolutionContext: MutationContext.User("approver", "Approver", "Resolve request"),
strategy: VersionedRequestResolutionStrategy.RejectStale);

var loaded = await store.Get(request.RequestId);

Assert.NotNull(loaded);
Assert.Equal(3, loaded.Decisions.Count);
Assert.Equal(MutationRequestDecisionType.RejectedAsStale, loaded.Decisions[^1].Type);
Assert.Equal(MutationRequestStatus.Rejected, loaded.Status);
Assert.Equal(1, loaded.Revision);
Assert.Equal(loaded, resolution.Request);
}

[Fact]
public async Task ResolveAndStore_throws_not_found_for_missing_request()
{
var store = new InMemoryMutationRequestStore();
var resolver = new MutationRequestVersionResolver();
var manager = new MutationRequestVersionResolutionManager(store, resolver);

var exception = await Assert.ThrowsAsync<MutationRequestNotFoundException>(() =>
manager.ResolveAndStore(
"missing-request",
currentStateVersion: "v15",
resolutionContext: MutationContext.User("approver", "Approver", "Resolve request"),
strategy: VersionedRequestResolutionStrategy.RejectStale));

Assert.Equal("missing-request", exception.RequestId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using ModularityKit.Mutator.Abstractions.Context;

namespace ModularityKit.Mutator.Governance.Abstractions.Resolution;

/// <summary>
/// Persists version-aware resolution outcomes for governed mutation requests.
/// </summary>
public interface IMutationRequestVersionResolutionManager
{
/// <summary>
/// Resolves a persisted request against the current state version and stores the resulting request state and decision history.
/// </summary>
Task<MutationRequestVersionResolution> ResolveAndStore(
string requestId,
string currentStateVersion,
MutationContext resolutionContext,
VersionedRequestResolutionStrategy strategy,
CancellationToken cancellationToken = default);
}
2 changes: 2 additions & 0 deletions src/Governance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Key types:
- `IMutationRequestStore`
- `IMutationRequestLifecycleManager`
- `IMutationRequestVersionResolver`
- `IMutationRequestVersionResolutionManager`
- `MutationRequestVersionResolution`
- `MutationRequestVersionResolutionOutcome`
- `VersionedRequestResolutionStrategy`
Expand All @@ -50,6 +51,7 @@ The initial runtime layer currently provides:
- `Runtime/Storage/InMemoryMutationRequestStore`
- `Runtime/Lifecycle/MutationRequestLifecycleManager`
- `Runtime/Resolution/MutationRequestVersionResolver`
- `Runtime/Resolution/MutationRequestVersionResolutionManager`

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace ModularityKit.Mutator.Governance.Runtime.Resolution;

/// <summary>
/// Describes how a governed request version compares to the currently observed state version.
/// </summary>
internal sealed record MutationRequestVersionEvaluation
{
/// <summary>
/// Expected version captured on the request before resolution.
/// </summary>
public string? ExpectedStateVersion { get; init; }

/// <summary>
/// Current version observed by the runtime during resolution.
/// </summary>
public string CurrentStateVersion { get; init; } = string.Empty;

/// <summary>
/// Indicates whether the expected and current versions differ.
/// </summary>
public bool IsStale { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using ModularityKit.Mutator.Governance.Abstractions.Requests;

namespace ModularityKit.Mutator.Governance.Runtime.Resolution;

/// <summary>
/// Evaluates whether a governed request still matches the currently observed state version.
/// </summary>
internal static class MutationRequestVersionEvaluator
{
/// <summary>
/// Compares the request expected version with the current state version and returns a normalized evaluation model.
/// </summary>
public static MutationRequestVersionEvaluation Evaluate(
MutationRequest request,
string currentStateVersion)
{
ArgumentNullException.ThrowIfNull(request);

if (string.IsNullOrWhiteSpace(currentStateVersion))
throw new ArgumentException("Current state version is required.", nameof(currentStateVersion));

var expectedStateVersion = request.ExpectedStateVersion;
var isStale = !string.IsNullOrWhiteSpace(expectedStateVersion)
&& !string.Equals(expectedStateVersion, currentStateVersion, StringComparison.Ordinal);

return new MutationRequestVersionEvaluation
{
ExpectedStateVersion = expectedStateVersion,
CurrentStateVersion = currentStateVersion,
IsStale = isStale
};
}
}
Loading
Loading