diff --git a/Docs/API-Reference.md b/Docs/API/API-Reference.md similarity index 93% rename from Docs/API-Reference.md rename to Docs/API/API-Reference.md index ac304df..53d7e9b 100644 --- a/Docs/API-Reference.md +++ b/Docs/API/API-Reference.md @@ -15,6 +15,7 @@ Complete API documentation for **ModularityKit.Mutators**. - [Related Types](#related-types) - [DI Extension Methods](#di-extension-methods) - [AddMutators](#addmutators) +- [Governance API](#governance-api) - [Execution Semantics](#execution-semantics) - [Best Practices](#best-practices) - [Complete Examples](#complete-examples) @@ -246,6 +247,19 @@ services.AddMutators(MutationEngineOptions.Strict, addDefaultLoggingInterceptor: --- +## Governance API + +Governance is documented separately so the core API reference can stay focused on direct mutation execution. + +- [Governance package overview](../../src/Governance/README.md) +- [Governance API usage](API.md) +- [Redis provider API](Redis.md) + +Governance-specific types such as `MutationRequestFactory`, `MutationRequestDecision`, and +`IGovernanceExecutionManager` live in the governance package, not the core mutation runtime. + +--- + ## Execution Semantics 1. Call `Validate(state)` @@ -272,4 +286,4 @@ services.AddMutators(MutationEngineOptions.Strict, addDefaultLoggingInterceptor: --- -> **Built with ❤️ for .NET developers** \ No newline at end of file +> **Built with ❤️ for .NET developers** diff --git a/Docs/API/API.md b/Docs/API/API.md new file mode 100644 index 0000000..f72b572 --- /dev/null +++ b/Docs/API/API.md @@ -0,0 +1,158 @@ +# Governance API + +This page shows the practical entry points used by the governance examples. + +## What this API is for + +Use the governance API when execution is not immediate and you need one of these steps first: + +- request creation +- approval workflow +- stale-version resolution +- governed execution +- request and decision history queries + +The core package owns direct mutation execution. Governance wraps that execution with a request +model and stateful decision history. + +## Request creation + +Use `MutationRequestFactory` when you want to create a request from explicit strings and values: + +```csharp +var request = MutationRequestFactory.PendingApproval( + 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", "Incident escalation"), + expectedStateVersion: "v10", + approvalRequirements: + [ + MutationApprovalRequirement.SingleActorStep("security-lead"), + MutationApprovalRequirement.SingleActorStep("platform-owner") + ]); +``` + +If you already have concrete CLR types, prefer the generic overloads such as: + +- `MutationRequestFactory.Approved()` +- `MutationRequestFactory.PendingApproval()` + +They remove repeated `stateType` and `mutationType` strings at the call site. + +### Factory choices + +- `Pending(...)` for a request that should enter the lifecycle without approval requirements +- `PendingApproval(...)` for a request that must collect one or more approval steps +- `Approved(...)` for a request that is terminally approved at creation time + +Use the generic overloads when the CLR types already exist. Use the string-based overloads when the +request metadata comes from an external system or serialized payload. + +## Decision history + +Use `MutationRequestDecision` when you need to record a lifecycle, approval, or version-resolution decision and the category is already known: + +```csharp +var decision = MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, + MutationContext.System("governance-runtime", "Approved by governance")); +``` + +The category-specific helpers are: + +- `MutationRequestDecision.Lifecycle(...)` +- `MutationRequestDecision.Approval(...)` +- `MutationRequestDecision.VersionResolution(...)` + +Use the low-level `MutationRequestDecision.Create(...)` only when the decision type is already dynamic and you do not know the category up front. + +### Decision categories + +- lifecycle decisions describe request state transitions such as submitted, pending, approved, rejected, executed, canceled, or expired +- approval decisions describe workflow-level approval actions such as requested, approved, rejected, or quorum satisfied +- version-resolution decisions describe how the runtime handled stale or matching versions before execution + +The helper methods exist so call sites stay readable when the category is already known. + +## Governed execution + +Use `IGovernanceExecutionManager.ExecuteApproved(...)` when the runtime should resolve an approved request and execute the mutation: + +```csharp +var execution = await executionManager.ExecuteApproved( + request.RequestId, + mutation, + currentState, + governanceContext: MutationContext.Service("governance-runtime", "Execute approved governance request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); +``` + +If your state implements `IVersionedState`, use the overload that accepts `currentState` directly and reads `state.Version` for both version selectors. + +### Typical execution flow + +1. create or load a request +2. approve the request if needed +3. resolve the request against the current state version +4. execute the underlying mutation through the core engine +5. record the terminal request decision + +That flow is what the `GovernedExecution` example demonstrates end to end. + +## Common operations + +### Create a pending approval request + +```csharp +var request = MutationRequestFactory.PendingApproval( + 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", "Incident escalation"), + expectedStateVersion: "v10", + approvalRequirements: + [ + MutationApprovalRequirement.SingleActorStep("security-lead"), + MutationApprovalRequirement.SingleActorStep("platform-owner") + ]); +``` + +### Record an approval decision + +```csharp +var decision = MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Approved, + MutationContext.User("approver-1", "Approver One", "Approved after review")); +``` + +### Execute an already approved request + +```csharp +var result = await executionManager.ExecuteApproved( + request.RequestId, + mutation, + currentState, + governanceContext: MutationContext.Service("governance-runtime", "Execute approved governance request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); +``` + +## Where to look next + +- `README.md` for the package overview +- `Examples/Governance/*/README.md` for runnable scenarios +- `src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs` for request creation +- `src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs` for decision helpers +- `src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs` for governed execution diff --git a/Docs/API/Redis.md b/Docs/API/Redis.md new file mode 100644 index 0000000..ada25f1 --- /dev/null +++ b/Docs/API/Redis.md @@ -0,0 +1,136 @@ +# Redis Governance Provider API + +This page covers the Redis-backed provider package `ModularityKit.Mutator.Governance.Redis`. + +It is the storage and query backend for governed requests. The package implements the public +request store and query store contracts while keeping Redis-specific persistence details internal. + +## What this API is for + +Use the Redis provider when you want the governance model from `ModularityKit.Mutator.Governance` +but need a persistent backing store instead of in-memory state. + +Typical reasons: + +- you want request persistence across process restarts +- you want Redis-backed query projections for approval queues and decision history +- you want the same governance API surface with a different storage backend +- you want to share governed request state across multiple runtime instances + +## Primary APIs + +### Dependency injection + +- `RedisGovernanceServiceCollectionExtensions.AddRedisGovernanceStore(...)` + +Registers the Redis-backed governance store and the query store against an existing +`IConnectionMultiplexer`. + +```csharp +using Microsoft.Extensions.DependencyInjection; +using ModularityKit.Mutator.Governance.Redis; +using StackExchange.Redis; + +var services = new ServiceCollection(); +var multiplexer = await ConnectionMultiplexer.ConnectAsync("localhost:6379"); + +services.AddRedisGovernanceStore( + multiplexer, + options => options.KeyPrefix = "modularitykit:governance"); +``` + +The extension method registers the provider-facing storage and query services against DI. + +### Configuration details + +`RedisMutationRequestStoreOptions.KeyPrefix` scopes all Redis keys created by the provider. + +Use a dedicated prefix per environment or application if you need isolation between deployments. +The examples use `modularitykit:governance` as a readable default. + +### Configuration + +- `RedisMutationRequestStoreOptions` + +Current option: + +- `KeyPrefix` - Redis key prefix used for all request data, indexes, and query projections + +### Storage facade + +- `RedisMutationRequestStore` + +Implements: + +- `IMutationRequestStore` +- `IMutationRequestQueryStore` + +Use it through DI rather than constructing it directly unless you are testing internals. + +### What gets stored + +The provider stores the data needed for: + +- request documents +- decision and approval projections +- query indexes and queue membership +- optimistic concurrency state for request writes + +The provider does not change the governance model. It only persists the same model through Redis. + +### Keyspace and key helpers + +- `RedisMutationRequestKeyspace` +- `RedisMutationRequestDocumentKeyFactory` + +These types define the Redis naming and partitioning rules for request documents, indexes, and +query views. + +## What the provider does + +The Redis provider handles: + +- request persistence +- query projection +- optimistic concurrency on request writes +- Redis index maintenance +- candidate selection for query execution + +It does not change the governance model itself. It only replaces the in-memory storage backend with +Redis-specific persistence and read paths. + +## Common usage + +### Register the provider + +```csharp +services.AddRedisGovernanceStore( + multiplexer, + options => options.KeyPrefix = "modularitykit:governance"); +``` + +### Resolve the stores + +```csharp +var requestStore = provider.GetRequiredService(); +var queryStore = provider.GetRequiredService(); +``` + +### Use the normal governance APIs + +Once registered, the rest of the application uses the same request and query contracts as the +in-memory provider. + +## Usage pattern + +1. Connect to Redis with `StackExchange.Redis` +2. Register the provider with `AddRedisGovernanceStore(...)` +3. Resolve `IMutationRequestStore` and `IMutationRequestQueryStore` from DI +4. Use the normal governance request and query APIs + +## Related docs + +- [`src/Redis/README.md`](../../src/Redis/README.md) +- [`Examples/Governance/RedisQueries/README.md`](../../Examples/Governance/RedisQueries/README.md) +- [`API-Reference.md`](API-Reference.md) +- [Governance API](API.md) diff --git a/Examples/Core/BillingQuotas/Mutations/DecreaseQuotaMutation.cs b/Examples/Core/BillingQuotas/Mutations/DecreaseQuotaMutation.cs index fa7e5f7..2b3ca1c 100644 --- a/Examples/Core/BillingQuotas/Mutations/DecreaseQuotaMutation.cs +++ b/Examples/Core/BillingQuotas/Mutations/DecreaseQuotaMutation.cs @@ -10,21 +10,23 @@ namespace BillingQuotas.Mutations; /// /// Mutation that decreases the quota for specific user by specified amount. /// -internal sealed record DecreaseQuotaMutation( - string UserId, - int Amount, - MutationContext Context -) : IMutation +internal sealed class DecreaseQuotaMutation( + string userId, + int amount, + MutationContext context +) : MutationBase( + CreateIntent( + operationName: "DecreaseQuota", + category: "Billing", + description: "Decrease user quota by given amount", + riskLevel: MutationRiskLevel.High), + context) { - public MutationIntent Intent { get; } = new() - { - OperationName = "DecreaseQuota", - Category = "Billing", - RiskLevel = MutationRiskLevel.High, - Description = "Decrease user quota by given amount" - }; + public string UserId { get; } = userId; + + public int Amount { get; } = amount; - public ValidationResult Validate(QuotaState state) + public override ValidationResult Validate(QuotaState state) { var result = new ValidationResult(); @@ -40,19 +42,15 @@ public ValidationResult Validate(QuotaState state) return result; } - public MutationResult Apply(QuotaState state) + public override MutationResult Apply(QuotaState state) { var quotas = state.UserQuotas.ToDictionary(kv => kv.Key, kv => kv.Value); quotas[UserId] = quotas.GetValueOrDefault(UserId) - Amount; var newState = state with { UserQuotas = quotas }; - var changes = ChangeSet.Single( - StateChange.Modified($"UserQuotas.{UserId}", null, quotas[UserId]) - ); - - return MutationResult.Success(newState, changes); + return Success( + newState, + StateChange.Modified($"UserQuotas.{UserId}", null, quotas[UserId])); } - - public MutationResult Simulate(QuotaState state) => Apply(state); -} \ No newline at end of file +} diff --git a/Examples/Core/BillingQuotas/Mutations/IncreaseQuotaMutation.cs b/Examples/Core/BillingQuotas/Mutations/IncreaseQuotaMutation.cs index bd3a9f2..269ad50 100644 --- a/Examples/Core/BillingQuotas/Mutations/IncreaseQuotaMutation.cs +++ b/Examples/Core/BillingQuotas/Mutations/IncreaseQuotaMutation.cs @@ -10,21 +10,23 @@ namespace BillingQuotas.Mutations; /// /// Mutation that increases the quota for specific user by given amount. /// -internal sealed record IncreaseQuotaMutation( - string UserId, - int Amount, - MutationContext Context -) : IMutation +internal sealed class IncreaseQuotaMutation( + string userId, + int amount, + MutationContext context +) : MutationBase( + CreateIntent( + operationName: "IncreaseQuota", + category: "Billing", + description: "Increase user quota by given amount", + riskLevel: MutationRiskLevel.Medium), + context) { - public MutationIntent Intent { get; } = new() - { - OperationName = "IncreaseQuota", - Category = "Billing", - RiskLevel = MutationRiskLevel.Medium, - Description = "Increase user quota by given amount" - }; + public string UserId { get; } = userId; + + public int Amount { get; } = amount; - public ValidationResult Validate(QuotaState state) + public override ValidationResult Validate(QuotaState state) { var result = new ValidationResult(); @@ -37,19 +39,15 @@ public ValidationResult Validate(QuotaState state) return result; } - public MutationResult Apply(QuotaState state) + public override MutationResult Apply(QuotaState state) { var quotas = state.UserQuotas.ToDictionary(kv => kv.Key, kv => kv.Value); quotas[UserId] = quotas.GetValueOrDefault(UserId) + Amount; var newState = state with { UserQuotas = quotas }; - var changes = ChangeSet.Single( - StateChange.Modified($"UserQuotas.{UserId}", null, quotas[UserId]) - ); - - return MutationResult.Success(newState, changes); + return Success( + newState, + StateChange.Modified($"UserQuotas.{UserId}", null, quotas[UserId])); } - - public MutationResult Simulate(QuotaState state) => Apply(state); -} \ No newline at end of file +} diff --git a/Examples/Core/BillingQuotas/Mutations/ResetQuotaMutation.cs b/Examples/Core/BillingQuotas/Mutations/ResetQuotaMutation.cs index 43812d9..b9494c4 100644 --- a/Examples/Core/BillingQuotas/Mutations/ResetQuotaMutation.cs +++ b/Examples/Core/BillingQuotas/Mutations/ResetQuotaMutation.cs @@ -4,27 +4,26 @@ using ModularityKit.Mutator.Abstractions.Engine; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Results; -using ValidationResult = ModularityKit.Mutator.Abstractions.Results.ValidationResult; namespace BillingQuotas.Mutations; /// /// Mutation that resets user's quota to zero. /// -internal sealed record ResetQuotaMutation( - string UserId, - MutationContext Context -) : IMutation +internal sealed class ResetQuotaMutation( + string userId, + MutationContext context +) : MutationBase( + CreateIntent( + operationName: "ResetQuota", + category: "Billing", + description: "Reset user quota to zero", + riskLevel: MutationRiskLevel.High), + context) { - public MutationIntent Intent { get; } = new() - { - OperationName = "ResetQuota", - Category = "Billing", - RiskLevel = MutationRiskLevel.High, - Description = "Reset user quota to zero" - }; + public string UserId { get; } = userId; - public ValidationResult Validate(QuotaState state) + public override ValidationResult Validate(QuotaState state) { var result = new ValidationResult(); @@ -34,19 +33,15 @@ public ValidationResult Validate(QuotaState state) return result; } - public MutationResult Apply(QuotaState state) + public override MutationResult Apply(QuotaState state) { var quotas = state.UserQuotas.ToDictionary(kv => kv.Key, kv => kv.Value); quotas[UserId] = 0; var newState = state with { UserQuotas = quotas }; - var changes = ChangeSet.Single( - StateChange.Modified($"UserQuotas.{UserId}", null, 0) - ); - - return MutationResult.Success(newState, changes); + return Success( + newState, + StateChange.Modified($"UserQuotas.{UserId}", null, 0)); } - - public MutationResult Simulate(QuotaState state) => Apply(state); -} \ No newline at end of file +} diff --git a/Examples/Core/BillingQuotas/Scenarios/EmergencyIncreaseScenario.cs b/Examples/Core/BillingQuotas/Scenarios/EmergencyIncreaseScenario.cs index fc8c725..6e208cd 100644 --- a/Examples/Core/BillingQuotas/Scenarios/EmergencyIncreaseScenario.cs +++ b/Examples/Core/BillingQuotas/Scenarios/EmergencyIncreaseScenario.cs @@ -55,13 +55,10 @@ internal static async Task Run(IMutationEngine engine) userName: "System Admin", reason: "Emergency quota increase"); - var mutations = new IMutation[] - { + var result = await engine.ExecuteBatchAsync( + state, new IncreaseQuotaMutation("alice", 15, ctx), - new IncreaseQuotaMutation("bob", 10, ctx) - }; - - var result = await engine.ExecuteBatchAsync(mutations, state); + new IncreaseQuotaMutation("bob", 10, ctx)); foreach (var res in result.Results) { @@ -75,4 +72,4 @@ internal static async Task Run(IMutationEngine engine) } } } -} \ No newline at end of file +} diff --git a/Examples/Core/BillingQuotas/Scenarios/MonthlyResetScenario.cs b/Examples/Core/BillingQuotas/Scenarios/MonthlyResetScenario.cs index 0cdbd1f..c639a6f 100644 --- a/Examples/Core/BillingQuotas/Scenarios/MonthlyResetScenario.cs +++ b/Examples/Core/BillingQuotas/Scenarios/MonthlyResetScenario.cs @@ -60,7 +60,7 @@ internal static async Task Run(IMutationEngine engine) .Select(user => new ResetQuotaMutation(user, ctx)) .ToArray(); - var result = await engine.ExecuteBatchAsync(mutations, state); + var result = await engine.ExecuteBatchAsync(state, mutations); Console.WriteLine($"Executed: {result.Results.Count}"); Console.WriteLine($"Success: {result.Results.Count(r => r.IsSuccess)}"); @@ -72,4 +72,4 @@ internal static async Task Run(IMutationEngine engine) foreach (var (user, quota) in state.UserQuotas) Console.WriteLine($" {user}: {quota}"); } -} \ No newline at end of file +} diff --git a/Examples/Core/FeatureFlags/Mutations/DisableFeatureMutation.cs b/Examples/Core/FeatureFlags/Mutations/DisableFeatureMutation.cs index 355318e..9b71a00 100644 --- a/Examples/Core/FeatureFlags/Mutations/DisableFeatureMutation.cs +++ b/Examples/Core/FeatureFlags/Mutations/DisableFeatureMutation.cs @@ -10,28 +10,30 @@ namespace FeatureFlags.Mutations; /// /// Mutation that disables a feature flag in the current . /// -internal sealed record DisableFeatureMutation(string FeatureName, MutationContext Context) : IMutation +internal sealed class DisableFeatureMutation(string featureName, MutationContext context) + : MutationBase( + CreateIntent( + operationName: "DisableFeature", + category: "Configuration", + description: "Disables a feature flag.", + riskLevel: MutationRiskLevel.High), + context) { - public MutationIntent Intent { get; } = new() - { - OperationName = "DisableFeature", - Category = "Configuration", - RiskLevel = MutationRiskLevel.High, - Description = "Disables a feature flag." - }; + public string FeatureName { get; } = featureName; - public MutationResult Apply(FeatureFlagsState state) + public override MutationResult Apply(FeatureFlagsState state) { var newFlags = new Dictionary(state.Flags); if (newFlags.ContainsKey(FeatureName)) newFlags[FeatureName] = false; var newState = state with { Flags = newFlags }; - var changes = ChangeSet.Single(StateChange.Modified($"Flags.{FeatureName}", true, false)); - return MutationResult.Success(newState, changes); + return Success( + newState, + StateChange.Modified($"Flags.{FeatureName}", true, false)); } - public ValidationResult Validate(FeatureFlagsState state) + public override ValidationResult Validate(FeatureFlagsState state) { var result = new ValidationResult(); @@ -41,6 +43,4 @@ public ValidationResult Validate(FeatureFlagsState state) result.AddError("FeatureName", $"Feature '{FeatureName}' does not exist"); return result; } - - public MutationResult Simulate(FeatureFlagsState state) => Apply(state); -} \ No newline at end of file +} diff --git a/Examples/Core/FeatureFlags/Mutations/EnableFeatureMutation.cs b/Examples/Core/FeatureFlags/Mutations/EnableFeatureMutation.cs index 34ad36a..094bf63 100644 --- a/Examples/Core/FeatureFlags/Mutations/EnableFeatureMutation.cs +++ b/Examples/Core/FeatureFlags/Mutations/EnableFeatureMutation.cs @@ -10,18 +10,19 @@ namespace FeatureFlags.Mutations; /// /// Mutation that enables feature flag in the current . /// -internal sealed record EnableFeatureMutation(string FeatureName, MutationContext Context) : IMutation +internal sealed class EnableFeatureMutation(string featureName, MutationContext context) + : MutationBase( + CreateIntent( + operationName: "EnableFeature", + category: "Security", + description: "Enables a feature flag.", + riskLevel: MutationRiskLevel.High, + tags: new HashSet { "auth" }), + context) { - public MutationIntent Intent { get; } = new() - { - OperationName = "EnableFeature", - Category = "Security", - Tags = new HashSet { "auth" }, - RiskLevel = MutationRiskLevel.High, - Description = "Enables a feature flag." - }; + public string FeatureName { get; } = featureName; - public MutationResult Apply(FeatureFlagsState state) + public override MutationResult Apply(FeatureFlagsState state) { if (state.Flags.TryGetValue(FeatureName, out var oldValue) && oldValue) return MutationResult.Success(state, ChangeSet.Empty); @@ -31,13 +32,12 @@ public MutationResult Apply(FeatureFlagsState state) [FeatureName] = true }; var newState = state with { Flags = newFlags }; - var changes = ChangeSet.Single( - StateChange.Modified($"Flags.{FeatureName}", oldValue, true) - ); - return MutationResult.Success(newState, changes); + return Success( + newState, + StateChange.Modified($"Flags.{FeatureName}", oldValue, true)); } - public ValidationResult Validate(FeatureFlagsState state) + public override ValidationResult Validate(FeatureFlagsState state) { var result = new ValidationResult(); if (string.IsNullOrEmpty(FeatureName)) @@ -50,6 +50,4 @@ public ValidationResult Validate(FeatureFlagsState state) } return result; } - - public MutationResult Simulate(FeatureFlagsState state) => Apply(state); -} \ No newline at end of file +} diff --git a/Examples/Core/FeatureFlags/Scenarios/BatchFeatureToggleScenario.cs b/Examples/Core/FeatureFlags/Scenarios/BatchFeatureToggleScenario.cs index 5f7fb16..eaca4c8 100644 --- a/Examples/Core/FeatureFlags/Scenarios/BatchFeatureToggleScenario.cs +++ b/Examples/Core/FeatureFlags/Scenarios/BatchFeatureToggleScenario.cs @@ -84,7 +84,7 @@ internal static async Task Run(IMutationEngine engine) ), }; - var batchResult = await engine.ExecuteBatchAsync(mutations, state); + var batchResult = await engine.ExecuteBatchAsync(state, mutations); MutationResultLogger.LogBatch(batchResult.Results); Console.WriteLine($"Success: {batchResult.SuccessCount}, Failed: {batchResult.FailureCount}"); diff --git a/Examples/Core/IamRoles/Mutations/GrantUserRoleMutation.cs b/Examples/Core/IamRoles/Mutations/GrantUserRoleMutation.cs index 9f7250d..f8939cb 100644 --- a/Examples/Core/IamRoles/Mutations/GrantUserRoleMutation.cs +++ b/Examples/Core/IamRoles/Mutations/GrantUserRoleMutation.cs @@ -10,21 +10,23 @@ namespace IamRoles.Mutations; /// /// Mutation that grants role to user in current . /// -internal sealed record GrantUserRoleMutation( - string UserId, - string Role, - MutationContext Context -) : IMutation +internal sealed class GrantUserRoleMutation( + string userId, + string role, + MutationContext context +) : MutationBase( + CreateIntent( + operationName: "GrantUserRole", + category: "Security", + description: "Grants role to a user", + riskLevel: MutationRiskLevel.Critical), + context) { - public MutationIntent Intent { get; } = new() - { - OperationName = "GrantUserRole", - Category = "Security", - RiskLevel = MutationRiskLevel.Critical, - Description = "Grants role to a user" - }; + public string UserId { get; } = userId; + + public string Role { get; } = role; - public ValidationResult Validate(UserPermissionsState state) + public override ValidationResult Validate(UserPermissionsState state) { var result = new ValidationResult(); @@ -41,7 +43,7 @@ public ValidationResult Validate(UserPermissionsState state) return result; } - public MutationResult Apply(UserPermissionsState state) + public override MutationResult Apply(UserPermissionsState state) { var rolesByUser = state.RolesByUser .ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value)); @@ -56,13 +58,8 @@ public MutationResult Apply(UserPermissionsState state) var newState = state with { RolesByUser = rolesByUser }; - var changes = ChangeSet.Single( - StateChange.Added($"RolesByUser.{UserId}", Role) - ); - - return MutationResult.Success(newState, changes); + return Success( + newState, + StateChange.Added($"RolesByUser.{UserId}", Role)); } - - public MutationResult Simulate(UserPermissionsState state) - => Apply(state); } diff --git a/Examples/Core/IamRoles/Mutations/RevokeUserRoleMutation.cs b/Examples/Core/IamRoles/Mutations/RevokeUserRoleMutation.cs index 4c171a6..56ba9ef 100644 --- a/Examples/Core/IamRoles/Mutations/RevokeUserRoleMutation.cs +++ b/Examples/Core/IamRoles/Mutations/RevokeUserRoleMutation.cs @@ -10,21 +10,23 @@ namespace IamRoles.Mutations; /// /// Mutation that revokes role from user in the current . /// -internal sealed record RevokeUserRoleMutation( - string UserId, - string Role, - MutationContext Context -) : IMutation +internal sealed class RevokeUserRoleMutation( + string userId, + string role, + MutationContext context +) : MutationBase( + CreateIntent( + operationName: "RevokeUserRole", + category: "Security", + description: "Revokes a role from a user", + riskLevel: MutationRiskLevel.High), + context) { - public MutationIntent Intent { get; } = new() - { - OperationName = "RevokeUserRole", - Category = "Security", - RiskLevel = MutationRiskLevel.High, - Description = "Revokes a role from a user" - }; + public string UserId { get; } = userId; + + public string Role { get; } = role; - public ValidationResult Validate(UserPermissionsState state) + public override ValidationResult Validate(UserPermissionsState state) { var result = new ValidationResult(); @@ -35,7 +37,7 @@ public ValidationResult Validate(UserPermissionsState state) return result; } - public MutationResult Apply(UserPermissionsState state) + public override MutationResult Apply(UserPermissionsState state) { var rolesByUser = state.RolesByUser .ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value)); @@ -44,13 +46,8 @@ public MutationResult Apply(UserPermissionsState state) var newState = state with { RolesByUser = rolesByUser }; - var changes = ChangeSet.Single( - StateChange.Removed($"RolesByUser.{UserId}", Role) - ); - - return MutationResult.Success(newState, changes); + return Success( + newState, + StateChange.Removed($"RolesByUser.{UserId}", Role)); } - - public MutationResult Simulate(UserPermissionsState state) - => Apply(state); } diff --git a/Examples/Core/IamRoles/Scenarios/BatchRoleMigrationScenario.cs b/Examples/Core/IamRoles/Scenarios/BatchRoleMigrationScenario.cs index d6521f2..eb58239 100644 --- a/Examples/Core/IamRoles/Scenarios/BatchRoleMigrationScenario.cs +++ b/Examples/Core/IamRoles/Scenarios/BatchRoleMigrationScenario.cs @@ -65,7 +65,7 @@ internal static async Task Run(IMutationEngine engine) new GrantUserRoleMutation("carol", "Manager", ctx) }; - var result = await engine.ExecuteBatchAsync(mutations, state); + var result = await engine.ExecuteBatchAsync(state, mutations); Console.WriteLine($"Executed: {result.Results.Count}"); Console.WriteLine($"Success: {result.Results.Count(r => r.IsSuccess)}"); @@ -78,4 +78,4 @@ internal static async Task Run(IMutationEngine engine) Console.WriteLine($" Policy: {decision.PolicyName} – {decision.Reason}"); } } -} \ No newline at end of file +} diff --git a/Examples/Core/WorkflowApprovals/Mutations/ApproveStepMutation.cs b/Examples/Core/WorkflowApprovals/Mutations/ApproveStepMutation.cs index 3e1e19c..b30c654 100644 --- a/Examples/Core/WorkflowApprovals/Mutations/ApproveStepMutation.cs +++ b/Examples/Core/WorkflowApprovals/Mutations/ApproveStepMutation.cs @@ -10,21 +10,23 @@ namespace WorkflowApprovals.Mutations; /// /// Mutation that approves specific step in an . /// -internal sealed record ApproveStepMutation( - int StepIndex, - string Approver, - MutationContext Context -) : IMutation +internal sealed class ApproveStepMutation( + int stepIndex, + string approver, + MutationContext context +) : MutationBase( + CreateIntent( + operationName: "ApproveStep", + category: "Workflow", + description: "Approve a workflow step", + riskLevel: MutationRiskLevel.High), + context) { - public MutationIntent Intent { get; } = new() - { - OperationName = "ApproveStep", - Category = "Workflow", - RiskLevel = MutationRiskLevel.High, - Description = "Approve a workflow step" - }; + public int StepIndex { get; } = stepIndex; + + public string Approver { get; } = approver; - public ValidationResult Validate(ApprovalWorkflowState state) + public override ValidationResult Validate(ApprovalWorkflowState state) { var result = new ValidationResult(); if (StepIndex < 0 || StepIndex >= state.Steps.Count) @@ -34,7 +36,7 @@ public ValidationResult Validate(ApprovalWorkflowState state) return result; } - public MutationResult Apply(ApprovalWorkflowState state) + public override MutationResult Apply(ApprovalWorkflowState state) { var steps = state.Steps.ToList(); var oldStep = steps[StepIndex]; @@ -47,12 +49,8 @@ public MutationResult Apply(ApprovalWorkflowState state) var newState = state with { Steps = steps }; - var changes = ChangeSet.Single( - StateChange.Modified($"Steps[{StepIndex}]", oldStep.Status, newStep.Status) - ); - - return MutationResult.Success(newState, changes); + return Success( + newState, + StateChange.Modified($"Steps[{StepIndex}]", oldStep.Status, newStep.Status)); } - - public MutationResult Simulate(ApprovalWorkflowState state) => Apply(state); -} \ No newline at end of file +} diff --git a/Examples/Core/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs b/Examples/Core/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs index 51aa6e2..0493b6a 100644 --- a/Examples/Core/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs +++ b/Examples/Core/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs @@ -11,20 +11,20 @@ namespace WorkflowApprovals.Mutations; /// /// Mutation that rejects the entire workflow in an . /// -internal sealed record RejectWorkflowMutation( - string Rejector, - MutationContext Context -) : IMutation +internal sealed class RejectWorkflowMutation( + string rejector, + MutationContext context +) : MutationBase( + CreateIntent( + operationName: "RejectWorkflow", + category: "Workflow", + description: "Rejects the entire workflow", + riskLevel: MutationRiskLevel.Critical), + context) { - public MutationIntent Intent { get; } = new() - { - OperationName = "RejectWorkflow", - Category = "Workflow", - RiskLevel = MutationRiskLevel.Critical, - Description = "Rejects the entire workflow" - }; + public string Rejector { get; } = rejector; - public ValidationResult Validate(ApprovalWorkflowState state) + public override ValidationResult Validate(ApprovalWorkflowState state) { var result = new ValidationResult(); if (string.IsNullOrEmpty(Rejector)) @@ -32,7 +32,7 @@ public ValidationResult Validate(ApprovalWorkflowState state) return result; } - public MutationResult Apply(ApprovalWorkflowState state) + public override MutationResult Apply(ApprovalWorkflowState state) { var steps = state.Steps.Select(s => s with { @@ -41,10 +41,9 @@ public MutationResult Apply(ApprovalWorkflowState state) }).ToList(); var newState = state with { Steps = steps }; - var changes = ChangeSet.Single(StateChange.Modified("Workflow", null, "Rejected")); - return MutationResult.Success( + return Success( newState, - changes, + StateChange.Modified("Workflow", null, "Rejected"), [ SideEffect.Critical( type: "WorkflowRejected", @@ -57,6 +56,4 @@ public MutationResult Apply(ApprovalWorkflowState state) }) ]); } - - public MutationResult Simulate(ApprovalWorkflowState state) => Apply(state); } diff --git a/Examples/Core/WorkflowApprovals/Mutations/StartApprovalMutation.cs b/Examples/Core/WorkflowApprovals/Mutations/StartApprovalMutation.cs index 6e5a956..7cbf162 100644 --- a/Examples/Core/WorkflowApprovals/Mutations/StartApprovalMutation.cs +++ b/Examples/Core/WorkflowApprovals/Mutations/StartApprovalMutation.cs @@ -11,21 +11,23 @@ namespace WorkflowApprovals.Mutations; /// /// Mutation that starts new approval workflow in an . /// -internal sealed record StartApprovalMutation( - string Initiator, - string[] StepNames, - MutationContext Context -) : IMutation +internal sealed class StartApprovalMutation( + string initiator, + string[] stepNames, + MutationContext context +) : MutationBase( + CreateIntent( + operationName: "StartWorkflow", + category: "Workflow", + description: "Starts a new approval workflow", + riskLevel: MutationRiskLevel.Medium), + context) { - public MutationIntent Intent { get; } = new() - { - OperationName = "StartWorkflow", - Category = "Workflow", - RiskLevel = MutationRiskLevel.Medium, - Description = "Starts a new approval workflow" - }; + public string Initiator { get; } = initiator; + + public string[] StepNames { get; } = stepNames; - public ValidationResult Validate(ApprovalWorkflowState state) + public override ValidationResult Validate(ApprovalWorkflowState state) { var result = new ValidationResult(); if (string.IsNullOrEmpty(Initiator)) @@ -35,7 +37,7 @@ public ValidationResult Validate(ApprovalWorkflowState state) return result; } - public MutationResult Apply(ApprovalWorkflowState state) + public override MutationResult Apply(ApprovalWorkflowState state) { var steps = StepNames.Select(name => new WorkflowStep(name)).ToList(); var newState = state with @@ -45,10 +47,9 @@ public MutationResult Apply(ApprovalWorkflowState state) Initiator = Initiator }; - var changes = ChangeSet.Single(StateChange.Added("Steps", steps)); - return MutationResult.Success( + return Success( newState, - changes, + StateChange.Added("Steps", steps), [ SideEffect.Create( type: "WorkflowStarted", @@ -61,6 +62,4 @@ public MutationResult Apply(ApprovalWorkflowState state) }) ]); } - - public MutationResult Simulate(ApprovalWorkflowState state) => Apply(state); } diff --git a/Examples/Governance/DecisionTaxonomy/Scenarios/GovernanceDecisionTaxonomyScenario.cs b/Examples/Governance/DecisionTaxonomy/Scenarios/GovernanceDecisionTaxonomyScenario.cs index 07a8dd1..a2e52b0 100644 --- a/Examples/Governance/DecisionTaxonomy/Scenarios/GovernanceDecisionTaxonomyScenario.cs +++ b/Examples/Governance/DecisionTaxonomy/Scenarios/GovernanceDecisionTaxonomyScenario.cs @@ -8,40 +8,49 @@ internal static class GovernanceDecisionTaxonomyScenario public static void Run() { PrintSection("Lifecycle Decisions"); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + + PrintDecision(MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, MutationContext.User("requester", "Requester", "Submit request"), reason: "Request was submitted into governance.")); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + + PrintDecision(MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, MutationContext.User("system", "System", "Request reached executable state"), reason: "Request is now approved for execution.")); + PrintSection("Approval Decisions"); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), + + PrintDecision(MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Requested, MutationContext.User("requester", "Requester", "Approval needed for sensitive change"), reason: "Sensitive change requires explicit sign-off.")); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted), + + PrintDecision(MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Granted, MutationContext.User("alice", "Alice", "Manager approved"), reason: "Manager granted the required approval.")); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Rejected), + + PrintDecision(MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Rejected, MutationContext.User("bob", "Bob", "Security rejected"), reason: "Security review rejected the request.")); PrintSection("Version Resolution Decisions"); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.Validated), + + PrintDecision(MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.Validated, MutationContext.User("approver", "Approver", "Version still matches"), reason: "Current state version still matches the approved request.")); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RejectedAsStale), + + PrintDecision(MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.RejectedAsStale, MutationContext.User("approver", "Approver", "State drift invalidated the request"), reason: "Request was rejected because the approved version is stale.")); - PrintDecision(MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RenewedApprovalRequired), + + PrintDecision(MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.RenewedApprovalRequired, MutationContext.User("approver", "Approver", "Re-approval required on latest state"), reason: "Request must be approved again on the latest state version.")); } @@ -61,4 +70,4 @@ private static void PrintDecision(MutationRequestDecision decision) Console.WriteLine($"Reason: {decision.Reason ?? "-"}"); Console.WriteLine(); } -} +} \ No newline at end of file diff --git a/Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs b/Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs index 0d30760..0e71fd2 100644 --- a/Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs +++ b/Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs @@ -5,6 +5,7 @@ using ModularityKit.Mutator.Abstractions.Engine; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts; using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; @@ -28,10 +29,8 @@ public static async Task Run() var resolutionManager = new MutationRequestVersionResolutionManager(store, new MutationRequestVersionResolver()); var executionManager = new GovernanceExecutionManager(store, resolutionManager, engine); - var request = await store.Create(MutationRequestFactory.Approved( + var request = await store.Create(MutationRequestFactory.Approved( stateId: "tenant-42:feature-flags", - stateType: "FeatureFlagState", - mutationType: nameof(EnableFeatureMutation), intent: new MutationIntent { OperationName = "EnableFeature", @@ -50,8 +49,6 @@ public static async Task Run() request.RequestId, new EnableFeatureMutation(MutationContext.Service("release-orchestrator", "Execute approved rollout"), "v11"), currentState, - currentState.Version, - resultingStateVersionProvider: state => state.Version, governanceContext: MutationContext.Service("governance-runtime", "Execute approved governance request"), strategy: VersionedRequestResolutionStrategy.RejectStale); @@ -64,20 +61,17 @@ public static async Task Run() Console.WriteLine($"Reason: {execution.Request.Decisions[^1].Reason}"); } - private sealed record FeatureFlagState(string StateId, bool IsEnabled, string Version); + private sealed record FeatureFlagState(string StateId, bool IsEnabled, string Version) : IVersionedState; - private sealed class EnableFeatureMutation(MutationContext context, string nextVersion) : IMutation + private sealed class EnableFeatureMutation(MutationContext context, string nextVersion) + : MutationBase( + CreateIntent( + operationName: "EnableFeature", + category: "Configuration", + description: "Enable a feature after governance approval"), + context) { - public MutationIntent Intent { get; } = new() - { - OperationName = "EnableFeature", - Category = "Configuration", - Description = "Enable a feature after governance approval" - }; - - public MutationContext Context { get; } = context; - - public MutationResult Apply(FeatureFlagState state) + public override MutationResult Apply(FeatureFlagState state) { var newState = state with { @@ -85,18 +79,16 @@ public MutationResult Apply(FeatureFlagState state) Version = nextVersion }; - return MutationResult.Success( + return Success( newState, - ChangeSet.Single(StateChange.Modified("IsEnabled", state.IsEnabled, newState.IsEnabled))); + StateChange.Modified("IsEnabled", state.IsEnabled, newState.IsEnabled)); } - public ValidationResult Validate(FeatureFlagState state) + public override ValidationResult Validate(FeatureFlagState state) { return state.IsEnabled ? ValidationResult.WithError("IsEnabled", "Feature is already enabled.") : ValidationResult.Success(); } - - public MutationResult Simulate(FeatureFlagState state) => Apply(state); } } diff --git a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs index b51f40f..a089acd 100644 --- a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs +++ b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs @@ -201,36 +201,36 @@ private static MutationRequest CreateRecentlyApprovedRequest( ], Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, MutationContext.User("requester", "Requester", "Submitted")) with { Timestamp = approvedAt.AddMinutes(-20) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Pending, MutationContext.User("requester", "Requester", "Pending approval")) with { Timestamp = approvedAt.AddMinutes(-19) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), + MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Requested, MutationContext.User("requester", "Requester", "Approval requested")) with { Timestamp = approvedAt.AddMinutes(-18) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted), + MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Granted, MutationContext.User(approverId, approverId, "Approved")) with { Timestamp = approvedAt }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, MutationContext.User(approverId, approverId, "Approved")) with { @@ -263,16 +263,15 @@ private static MutationRequest CreateResolvedRequest( UpdatedAt = decisionTimestamp, Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, MutationContext.Service("governance-runtime", "Submitted")) with { Timestamp = decisionTimestamp.AddMinutes(-30) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution( - MutationRequestVersionResolutionDecisionType.Validated), + MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.Validated, MutationContext.Service("governance-runtime", "Validated current version")) with { @@ -306,22 +305,22 @@ private static MutationRequest CreateExecutedRequest( ExecutedAt = executedAt, Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, MutationContext.Service("governance-runtime", "Submitted")) with { Timestamp = executedAt.AddMinutes(-15) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, MutationContext.Service("governance-runtime", "Approved")) with { Timestamp = executedAt.AddMinutes(-5) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Executed, MutationContext.Service("governance-runtime", "Executed")) with { diff --git a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs index f5601e6..3cb5fd5 100644 --- a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs +++ b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs @@ -152,22 +152,22 @@ private static MutationRequest CreateExecutedRequest( ExecutedAt = executedAt, Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, MutationContext.Service("governance-runtime", "Submitted")) with { Timestamp = executedAt.AddMinutes(-15) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, MutationContext.Service("governance-runtime", "Approved")) with { Timestamp = executedAt.AddMinutes(-5) }, - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Executed, MutationContext.Service("governance-runtime", "Executed")) with { diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs index 52d6939..ea78b9d 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs @@ -7,15 +7,16 @@ using ModularityKit.Mutator.Abstractions.History; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts; using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; using ModularityKit.Mutator.Governance.Runtime.Execution.Orchestration; using ModularityKit.Mutator.Governance.Runtime.Resolution.Execution; using ModularityKit.Mutator.Governance.Runtime.Storage; -using ModularityKit.Mutator.Governance.Tests.TestSupport; using ModularityKit.Mutator.Runtime; using Xunit; @@ -37,7 +38,16 @@ public async Task ExecuteApproved_executes_request_persists_resulting_version_an var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); - var request = await requestStore.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10")); + var request = await requestStore.Create(MutationRequestFactory.Approved( + stateId: "tenant-42:roles", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated access" + }, + context: MutationContext.User("requester", "Requester", "Need access"), + expectedStateVersion: "v10")); var mutation = new PromoteRoleMutation( MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), nextVersion: "v11"); @@ -47,8 +57,6 @@ public async Task ExecuteApproved_executes_request_persists_resulting_version_an request.RequestId, mutation, state, - currentStateVersion: state.Version, - resultingStateVersionProvider: updated => updated.Version, governanceContext: MutationContext.Service("governance-runtime", "Execute approved request"), strategy: VersionedRequestResolutionStrategy.RejectStale); @@ -84,7 +92,16 @@ public async Task ExecuteApproved_does_not_execute_when_stale_resolution_rejects var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); - var request = await requestStore.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10")); + var request = await requestStore.Create(MutationRequestFactory.Approved( + stateId: "tenant-42:roles", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated access" + }, + context: MutationContext.User("requester", "Requester", "Need access"), + expectedStateVersion: "v10")); var mutation = new PromoteRoleMutation( MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), nextVersion: "v11"); @@ -94,8 +111,6 @@ public async Task ExecuteApproved_does_not_execute_when_stale_resolution_rejects request.RequestId, mutation, state, - currentStateVersion: state.Version, - resultingStateVersionProvider: updated => updated.Version, governanceContext: MutationContext.Service("governance-runtime", "Reject stale request"), strategy: VersionedRequestResolutionStrategy.RejectStale); @@ -120,7 +135,16 @@ public async Task ExecuteApproved_requires_renewed_approval_before_execution_whe var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); - var request = await requestStore.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10")); + var request = await requestStore.Create(MutationRequestFactory.Approved( + stateId: "tenant-42:roles", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated access" + }, + context: MutationContext.User("requester", "Requester", "Need access"), + expectedStateVersion: "v10")); var mutation = new PromoteRoleMutation( MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), nextVersion: "v11"); @@ -130,8 +154,6 @@ public async Task ExecuteApproved_requires_renewed_approval_before_execution_whe request.RequestId, mutation, state, - currentStateVersion: state.Version, - resultingStateVersionProvider: updated => updated.Version, governanceContext: MutationContext.Service("governance-runtime", "Require renewed approval"), strategy: VersionedRequestResolutionStrategy.RequireRenewedApproval); @@ -155,7 +177,16 @@ public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_ var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); - var request = await requestStore.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10")); + var request = await requestStore.Create(MutationRequestFactory.Approved( + stateId: "tenant-42:roles", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated access" + }, + context: MutationContext.User("requester", "Requester", "Need access"), + expectedStateVersion: "v10")); var mutation = new PromoteRoleMutation( MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), nextVersion: "v16"); @@ -165,8 +196,6 @@ public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_ request.RequestId, mutation, state, - currentStateVersion: state.Version, - resultingStateVersionProvider: updated => updated.Version, governanceContext: MutationContext.Service("governance-runtime", "Revalidate and execute"), strategy: VersionedRequestResolutionStrategy.RevalidateOnLatestState); @@ -185,7 +214,7 @@ public async Task ExecuteApproved_revalidates_and_executes_against_latest_state_ decision => decision.Type == MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RevalidationRequired)); } - private sealed record RoleState(string StateId, string Role, string Version) + private sealed record RoleState(string StateId, string Role, string Version) : IVersionedState { public static RoleState Create(string stateId, string role, string version) => new(stateId, role, version); } diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestDecisionTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestDecisionTests.cs new file mode 100644 index 0000000..3cc182a --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestDecisionTests.cs @@ -0,0 +1,43 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Requests; + +public sealed class MutationRequestDecisionTests +{ + [Fact] + public void Lifecycle_factory_wraps_lifecycle_type() + { + var decision = MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, + MutationContext.User("alice", "Alice", "Approved"), + reason: "Approved by operator"); + + Assert.Equal(MutationRequestDecisionCategory.Lifecycle, decision.Type.Category); + Assert.Equal(nameof(MutationRequestLifecycleDecisionType.Approved), decision.Type.Code); + Assert.Equal("Approved by operator", decision.Reason); + } + + [Fact] + public void Approval_factory_wraps_approval_type() + { + var decision = MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.QuorumSatisfied, + MutationContext.User("bob", "Bob", "Quorum satisfied")); + + Assert.Equal(MutationRequestDecisionCategory.Approval, decision.Type.Category); + Assert.Equal(nameof(MutationRequestApprovalDecisionType.QuorumSatisfied), decision.Type.Code); + } + + [Fact] + public void VersionResolution_factory_wraps_version_resolution_type() + { + var decision = MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.RejectedAsStale, + MutationContext.User("carol", "Carol", "Stale")); + + Assert.Equal(MutationRequestDecisionCategory.VersionResolution, decision.Type.Category); + Assert.Equal(nameof(MutationRequestVersionResolutionDecisionType.RejectedAsStale), decision.Type.Code); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestFactoryTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestFactoryTests.cs new file mode 100644 index 0000000..ce9bc8d --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Requests/MutationRequestFactoryTests.cs @@ -0,0 +1,75 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; +using Xunit; + +namespace ModularityKit.Mutator.Governance.Tests.Requests; + +public sealed class MutationRequestFactoryTests +{ + [Fact] + public void Approved_generic_overload_infers_state_and_mutation_type_names() + { + var request = MutationRequestFactory.Approved( + stateId: "tenant-42:test", + intent: new MutationIntent + { + OperationName = "Test", + Category = "Test" + }, + context: MutationContext.User("requester", "Requester", "Generic helper"), + expectedStateVersion: "v1"); + + Assert.Equal(nameof(TestState), request.StateType); + Assert.Equal(nameof(TestMutation), request.MutationType); + Assert.Equal(MutationRequestStatus.Approved, request.Status); + Assert.Equal("v1", request.ExpectedStateVersion); + } + + [Fact] + public void PendingApproval_generic_overload_infers_state_and_mutation_type_names() + { + var request = MutationRequestFactory.PendingApproval( + stateId: "tenant-42:test", + intent: new MutationIntent + { + OperationName = "Test", + Category = "Test" + }, + context: MutationContext.User("requester", "Requester", "Generic helper"), + requirements: + [ + PolicyRequirement.Approval("alice", "Approval required") + ], + expectedStateVersion: "v1"); + + Assert.Equal(nameof(TestState), request.StateType); + Assert.Equal(nameof(TestMutation), request.MutationType); + Assert.Equal(MutationRequestStatus.Pending, request.Status); + Assert.Equal(PendingMutationReason.Approval, request.PendingReason); + Assert.Single(request.ApprovalRequirements); + } + + private sealed record TestState; + + private sealed class TestMutation : IMutation + { + public MutationIntent Intent { get; } = new() + { + OperationName = "Test", + Category = "Test" + }; + + public MutationContext Context { get; } = MutationContext.System("Test"); + + public MutationResult Apply(TestState state) => throw new NotImplementedException(); + + public ValidationResult Validate(TestState state) => ValidationResult.Success(); + + public MutationResult Simulate(TestState state) => Apply(state); + } +} diff --git a/Tests/ModularityKit.Mutator.Tests/Runtime/MutationBaseTests.cs b/Tests/ModularityKit.Mutator.Tests/Runtime/MutationBaseTests.cs new file mode 100644 index 0000000..79fe974 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/Runtime/MutationBaseTests.cs @@ -0,0 +1,103 @@ +using Microsoft.Extensions.DependencyInjection; +using ModularityKit.Mutator.Abstractions.Changes; +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Runtime; +using Xunit; + +namespace ModularityKit.Mutator.Tests.Runtime; + +public sealed class MutationBaseTests +{ + [Fact] + public void MutationBase_provides_successful_validation_by_default() + { + var mutation = new SampleMutation(); + + Assert.True(mutation.Validate(new SampleState("initial")).IsValid); + } + + [Fact] + public void MutationBase_defaults_simulate_to_apply() + { + var mutation = new SampleMutation(); + var state = new SampleState("initial"); + + var simulated = mutation.Simulate(state); + + Assert.True(simulated.IsSuccess); + Assert.Equal("applied", simulated.NewState!.Value); + Assert.Equal("Sample", mutation.Intent.OperationName); + Assert.Equal("tester", mutation.Context.ActorId); + } + + [Fact] + public void MutationResult_success_can_be_created_from_single_change() + { + var result = MutationResult.Success( + new SampleState("next"), + StateChange.Modified("Value", "initial", "next")); + + Assert.True(result.IsSuccess); + Assert.Equal("next", result.NewState!.Value); + Assert.Single(result.Changes.Changes); + Assert.Equal("Value", result.Changes.Changes[0].Path); + } + + [Fact] + public void MutationBase_provides_success_helper_for_single_change() + { + var mutation = new SampleMutation(); + + var result = mutation.Apply(new SampleState("initial")); + + Assert.True(result.IsSuccess); + Assert.Equal("applied", result.NewState!.Value); + Assert.Single(result.Changes.Changes); + } + + [Fact] + public async Task ExecuteBatchAsync_supports_params_overload() + { + var services = new ServiceCollection(); + services.AddMutators(); + + await using var provider = services.BuildServiceProvider(); + var engine = provider.GetRequiredService(); + var state = new SampleState("initial"); + + var result = await engine.ExecuteBatchAsync( + state, + new SampleMutation("first"), + new SampleMutation("second")); + + Assert.True(result.IsSuccess); + Assert.Equal("second", result.FinalState!.Value); + Assert.Equal(2, result.Results.Count); + } + + private sealed record SampleState(string Value); + + private sealed class SampleMutation : MutationBase + { + public SampleMutation(string? nextValue = null) + : base( + CreateIntent( + operationName: "Sample", + category: "Test", + description: "Sample mutation"), + MutationContext.User("tester", "Tester", "Sample run")) + { + NextValue = nextValue ?? "applied"; + } + + private string NextValue { get; } + + public override MutationResult Apply(SampleState state) + => Success( + state with { Value = NextValue }, + StateChange.Modified("Value", state.Value, NextValue)); + } +} diff --git a/src/Abstractions/Engine/IMutation.cs b/src/Abstractions/Engine/IMutation.cs index bd5a1a9..1643b17 100644 --- a/src/Abstractions/Engine/IMutation.cs +++ b/src/Abstractions/Engine/IMutation.cs @@ -14,6 +14,10 @@ namespace ModularityKit.Mutator.Abstractions.Engine; /// and the operations to apply, validate, or simulate the mutation. /// /// +/// For simpler implementations, provides default behavior for +/// validation and simulation. +/// +/// /// Typical usage involves: /// /// Creating a mutation instance with a descriptive . diff --git a/src/Abstractions/Engine/IMutationEngine.cs b/src/Abstractions/Engine/IMutationEngine.cs index 13315c6..8f4b40e 100644 --- a/src/Abstractions/Engine/IMutationEngine.cs +++ b/src/Abstractions/Engine/IMutationEngine.cs @@ -72,6 +72,24 @@ Task> ExecuteBatchAsync( TState state, CancellationToken cancellationToken = default); + /// + /// Executes a batch of mutations as a single logical transaction. + /// + /// The type of the state being mutated. + /// The initial state. + /// The mutations to execute in order. + /// Token used to cancel execution. + /// + /// A describing the outcome of the batch execution. + /// + /// + /// This overload is optimized for call sites that want a compact mutation list without + /// manually allocating an array. + /// + Task> ExecuteBatchAsync( + TState state, + params IMutation[] mutations); + /// /// Registers a global mutation policy. /// diff --git a/src/Abstractions/Engine/MutationBase.cs b/src/Abstractions/Engine/MutationBase.cs new file mode 100644 index 0000000..aa731e8 --- /dev/null +++ b/src/Abstractions/Engine/MutationBase.cs @@ -0,0 +1,101 @@ +using ModularityKit.Mutator.Abstractions.Changes; +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Effects; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Results; + +namespace ModularityKit.Mutator.Abstractions.Engine; + +/// +/// Base class for mutation implementations that want sensible defaults for common behavior. +/// +/// +/// +/// Inheriting from removes repeated boilerplate for: +/// +/// +/// storing and +/// defaulting to success +/// defaulting to +/// building common instances +/// +/// +/// The base class is optional. Mutations can still implement directly +/// when they need a different shape. +/// +/// +/// The type of state the mutation operates on. +public abstract class MutationBase : IMutation +{ + /// + /// Initializes a new mutation base with the provided intent and context. + /// + /// The mutation intent. + /// The execution context. + protected MutationBase(MutationIntent intent, MutationContext context) + { + Intent = intent ?? throw new ArgumentNullException(nameof(intent)); + Context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public MutationIntent Intent { get; } + + /// + public MutationContext Context { get; } + + /// + public abstract MutationResult Apply(TState state); + + /// + public virtual ValidationResult Validate(TState state) => ValidationResult.Success(); + + /// + public virtual MutationResult Simulate(TState state) => Apply(state); + + /// + /// Creates a successful mutation result from a single state change. + /// + /// The new state after the mutation. + /// The single change applied. + /// Optional list of side effects. + /// A representing success. + protected static MutationResult Success( + TState newState, + StateChange change, + IReadOnlyList? sideEffects = null) + => MutationResult.Success(newState, change, sideEffects); + + /// + /// Creates a common instance. + /// + /// The operation name. + /// The mutation category. + /// Optional human-readable description. + /// The mutation risk level. + /// Whether the mutation can be reversed. + /// Optional blast radius estimate. + /// Optional classification tags. + /// Optional metadata attached to the intent. + /// A configured . + protected static MutationIntent CreateIntent( + string operationName, + string category, + string? description = null, + MutationRiskLevel riskLevel = MutationRiskLevel.Low, + bool isReversible = true, + BlastRadius? estimatedBlastRadius = null, + IReadOnlySet? tags = null, + IReadOnlyDictionary? metadata = null) + => new() + { + OperationName = operationName, + Category = category, + Description = description, + RiskLevel = riskLevel, + IsReversible = isReversible, + EstimatedBlastRadius = estimatedBlastRadius, + Tags = tags ?? new HashSet(), + Metadata = metadata ?? new Dictionary() + }; +} diff --git a/src/Abstractions/Results/MutationResult.cs b/src/Abstractions/Results/MutationResult.cs index 04e3850..a86229a 100644 --- a/src/Abstractions/Results/MutationResult.cs +++ b/src/Abstractions/Results/MutationResult.cs @@ -77,6 +77,19 @@ public static MutationResult Success( SideEffects = sideEffects ?? [] }; + /// + /// Creates a successful mutation result from a single state change. + /// + /// The new state after the mutation. + /// The single change applied. + /// Optional list of side effects. + /// A representing success. + public static MutationResult Success( + TState newState, + StateChange change, + IReadOnlyList? sideEffects = null) + => Success(newState, ChangeSet.Single(change), sideEffects); + /// /// Creates a failed mutation result due to validation errors. /// diff --git a/src/Governance/Abstractions/Execution/Contracts/IGovernanceExecutionManager.cs b/src/Governance/Abstractions/Execution/Contracts/IGovernanceExecutionManager.cs index ca79063..ed113f9 100644 --- a/src/Governance/Abstractions/Execution/Contracts/IGovernanceExecutionManager.cs +++ b/src/Governance/Abstractions/Execution/Contracts/IGovernanceExecutionManager.cs @@ -22,4 +22,16 @@ Task> ExecuteApproved( MutationContext governanceContext, VersionedRequestResolutionStrategy strategy, CancellationToken cancellationToken = default); + + /// + /// Executes an approved governed mutation request against a versioned state snapshot. + /// + Task> ExecuteApproved( + string requestId, + IMutation mutation, + TState currentState, + MutationContext governanceContext, + VersionedRequestResolutionStrategy strategy, + CancellationToken cancellationToken = default) + where TState : IVersionedState; } diff --git a/src/Governance/Abstractions/Execution/Contracts/IVersionedState.cs b/src/Governance/Abstractions/Execution/Contracts/IVersionedState.cs new file mode 100644 index 0000000..d31e01c --- /dev/null +++ b/src/Governance/Abstractions/Execution/Contracts/IVersionedState.cs @@ -0,0 +1,12 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts; + +/// +/// Represents a state snapshot that carries a stable version token. +/// +public interface IVersionedState +{ + /// + /// Gets the current version token for the state snapshot. + /// + string Version { get; } +} diff --git a/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs index 852272c..fe238e3 100644 --- a/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs +++ b/src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs @@ -32,6 +32,48 @@ public sealed record MutationRequestDecision /// public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); + /// + /// Creates a lifecycle decision entry. + /// + public static MutationRequestDecision Lifecycle( + MutationRequestLifecycleDecisionType type, + MutationContext context, + string? reason = null, + IReadOnlyDictionary? metadata = null) + => Create( + MutationRequestDecisionType.Lifecycle(type), + context, + reason, + metadata); + + /// + /// Creates an approval decision entry. + /// + public static MutationRequestDecision Approval( + MutationRequestApprovalDecisionType type, + MutationContext context, + string? reason = null, + IReadOnlyDictionary? metadata = null) + => Create( + MutationRequestDecisionType.Approval(type), + context, + reason, + metadata); + + /// + /// Creates a version-resolution decision entry. + /// + public static MutationRequestDecision VersionResolution( + MutationRequestVersionResolutionDecisionType type, + MutationContext context, + string? reason = null, + IReadOnlyDictionary? metadata = null) + => Create( + MutationRequestDecisionType.VersionResolution(type), + context, + reason, + metadata); + /// /// Creates a new request decision entry. /// diff --git a/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs b/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs index 81d4f37..9b692ff 100644 --- a/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs +++ b/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs @@ -1,4 +1,5 @@ using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Policies; using ModularityKit.Mutator.Governance.Abstractions.Approval.Mapping; @@ -13,6 +14,31 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; /// public static class MutationRequestFactory { + /// + /// Creates a request that should enter the pending lifecycle using type inference for the target state and mutation. + /// + public static MutationRequest Pending( + string stateId, + MutationIntent intent, + MutationContext context, + PendingMutationReason pendingReason, + IReadOnlyList? requirements = null, + string? expectedStateVersion = null, + DateTimeOffset? expiresAt = null, + IReadOnlyDictionary? metadata = null) + where TMutation : IMutation + => Pending( + stateId, + typeof(TState).Name, + typeof(TMutation).Name, + intent, + context, + pendingReason, + requirements, + expectedStateVersion, + expiresAt, + metadata); + /// /// Creates a request that should enter the pending lifecycle. /// @@ -43,18 +69,41 @@ public static MutationRequest Pending( Metadata = metadata ?? new Dictionary(), Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, context, reason: context.Reason), - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Pending, context, reason: $"Request entered pending lifecycle for reason '{pendingReason}'.") ] }; } + /// + /// Creates a request that enters pending approval using type inference for the target state and mutation. + /// + public static MutationRequest PendingApproval( + string stateId, + MutationIntent intent, + MutationContext context, + IReadOnlyList requirements, + string? expectedStateVersion = null, + DateTimeOffset? expiresAt = null, + IReadOnlyDictionary? metadata = null) + where TMutation : IMutation + => PendingApproval( + stateId, + typeof(TState).Name, + typeof(TMutation).Name, + intent, + context, + requirements, + expectedStateVersion, + expiresAt, + metadata); + /// /// Creates a request that enters pending approval with concrete request-level approval requirements. /// @@ -91,16 +140,16 @@ public static MutationRequest PendingApproval( Metadata = metadata ?? new Dictionary(), Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, context, reason: context.Reason), - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Pending, context, reason: "Request entered pending approval."), - MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), + MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.Requested, context, reason: $"Request requires {approvalRequirements.Count} approval action(s).", metadata: new Dictionary @@ -111,6 +160,25 @@ public static MutationRequest PendingApproval( }; } + /// + /// Creates a request that is immediately approved for execution using type inference for the target state and mutation. + /// + public static MutationRequest Approved( + string stateId, + MutationIntent intent, + MutationContext context, + string? expectedStateVersion = null, + IReadOnlyDictionary? metadata = null) + where TMutation : IMutation + => Approved( + stateId, + typeof(TState).Name, + typeof(TMutation).Name, + intent, + context, + expectedStateVersion, + metadata); + /// /// Creates a request that is immediately approved for execution. /// @@ -135,12 +203,12 @@ public static MutationRequest Approved( Metadata = metadata ?? new Dictionary(), Decisions = [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Submitted, context, reason: context.Reason), - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, context, reason: "Approved at submission time") ] diff --git a/src/Governance/README.md b/src/Governance/README.md index 9cefb77..c497cda 100644 --- a/src/Governance/README.md +++ b/src/Governance/README.md @@ -44,6 +44,14 @@ Console.WriteLine($"{persisted.RequestId} -> {persisted.Status}"); - `MutationRequestStatus` - `PendingMutationReason` +The request factory also has generic overloads such as `Approved()` and +`PendingApproval()` when you already have concrete CLR types and do not want +to repeat `stateType` / `mutationType` strings at the call site. + +`MutationRequestDecision` also exposes category-specific helpers such as `Lifecycle(...)`, +`Approval(...)`, and `VersionResolution(...)` so you do not have to spell out the low-level +decision wrapper when the category is already known. + ### Storage - `IMutationRequestStore` @@ -83,6 +91,12 @@ Console.WriteLine($"{persisted.RequestId} -> {persisted.Status}"); - `GovernanceExecutionManager` - `GovernedExecutionResult` +`IGovernanceExecutionManager.ExecuteApproved(...)` also has an overload for `IVersionedState` +implementations, which removes the need to pass the current and resulting version selectors when +both come from `state.Version`. + +See [`Docs/API/API.md`](../../Docs/API/API.md) for the practical usage surface and example call patterns. + ## Package structure The project is organized by governance concern: diff --git a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs index 8c5db0c..6b1d1ca 100644 --- a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs +++ b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalDecisionExecutor.cs @@ -106,8 +106,8 @@ private static List BuildDecisionHistory( string.Equals(requirement.ApprovalGroupId, resolvedRequirement.ApprovalGroupId, StringComparison.Ordinal) && requirement.Status == MutationApprovalRequirementStatus.Satisfied)) { - decisions.Add(MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.QuorumSatisfied), + decisions.Add(MutationRequestDecision.Approval( + MutationRequestApprovalDecisionType.QuorumSatisfied, decisionContext, reason: $"Approval quorum satisfied for group '{resolvedRequirement.ApprovalGroupId}'.", metadata: new Dictionary @@ -122,15 +122,15 @@ private static List BuildDecisionHistory( if (finalizeApprovedRequest && isFullyApproved) { - decisions.Add(MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + decisions.Add(MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Approved, decisionContext, reason: "All approval requirements were fulfilled.")); } else if (!finalizeApprovedRequest) { - decisions.Add(MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected), + decisions.Add(MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Rejected, decisionContext, reason: reason ?? rejection?.Message ?? decisionContext.Reason ?? "Request was rejected during approval workflow.")); } diff --git a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs index bd54bf7..104cd53 100644 --- a/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs +++ b/src/Governance/Runtime/Approval/Execution/MutationRequestApprovalExpirationExecutor.cs @@ -60,8 +60,8 @@ requirement.ExpiresAt is not null && ? "Approval requirement expired." : $"Approval requirement expired at '{requirement.ExpiresAt:O}'."))); - decisions.Add(MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected), + decisions.Add(MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Rejected, decisionContext, reason: "Request was rejected because one or more approval requirements expired.")); diff --git a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs index c2da7a5..2b16e54 100644 --- a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs +++ b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs @@ -120,4 +120,22 @@ await _outcomeHandler.PersistRejectedExecution( executedRequest, resultingStateVersion); } + + public Task> ExecuteApproved( + string requestId, + IMutation mutation, + TState currentState, + MutationContext governanceContext, + VersionedRequestResolutionStrategy strategy, + CancellationToken cancellationToken = default) + where TState : IVersionedState + => ExecuteApproved( + requestId, + mutation, + currentState, + currentState.Version, + state => state.Version, + governanceContext, + strategy, + cancellationToken); } diff --git a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs index 66b1eca..e360e24 100644 --- a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs +++ b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs @@ -14,8 +14,8 @@ public static MutationRequestDecision CreateRejectedDecision( string reason, IReadOnlyDictionary metadata) { - return MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected), + return MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Rejected, governanceContext, reason, metadata); @@ -26,8 +26,8 @@ public static MutationRequestDecision CreateExecutedDecision( string resultingStateVersion, MutationResult mutationResult) { - return MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), + return MutationRequestDecision.Lifecycle( + MutationRequestLifecycleDecisionType.Executed, governanceContext, "Governed request executed successfully.", new Dictionary diff --git a/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionFactory.cs b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionFactory.cs index 829aa56..72f6b2d 100644 --- a/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionFactory.cs +++ b/src/Governance/Runtime/Resolution/Execution/MutationRequestVersionResolutionFactory.cs @@ -20,8 +20,8 @@ public static MutationRequestVersionResolution BuildValidated( MutationRequestVersionEvaluation evaluation, MutationContext resolutionContext) { - var validatedDecision = MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.Validated), + var validatedDecision = MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.Validated, resolutionContext, reason: MutationRequestVersionResolutionState.BuildValidatedReason( evaluation.ExpectedStateVersion, @@ -48,8 +48,8 @@ public static MutationRequestVersionResolution BuildRejectedAsStale( MutationRequestVersionEvaluation evaluation, MutationContext resolutionContext) { - var decision = MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RejectedAsStale), + var decision = MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.RejectedAsStale, resolutionContext, reason: MutationRequestVersionResolutionState.BuildStaleReason( evaluation.ExpectedStateVersion!, @@ -81,8 +81,8 @@ public static MutationRequestVersionResolution BuildRenewedApprovalRequired( MutationRequestVersionEvaluation evaluation, MutationContext resolutionContext) { - var decision = MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RenewedApprovalRequired), + var decision = MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.RenewedApprovalRequired, resolutionContext, reason: MutationRequestVersionResolutionState.BuildStaleReason( evaluation.ExpectedStateVersion!, @@ -114,8 +114,8 @@ public static MutationRequestVersionResolution BuildRevalidationRequired( MutationRequestVersionEvaluation evaluation, MutationContext resolutionContext) { - var decision = MutationRequestDecision.Create( - MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RevalidationRequired), + var decision = MutationRequestDecision.VersionResolution( + MutationRequestVersionResolutionDecisionType.RevalidationRequired, resolutionContext, reason: MutationRequestVersionResolutionState.BuildStaleReason( evaluation.ExpectedStateVersion!, diff --git a/src/README.md b/src/README.md index 0ccf497..98ae551 100644 --- a/src/README.md +++ b/src/README.md @@ -31,26 +31,21 @@ Console.WriteLine(result.NewState!.Quota); public sealed record QuotaState(string StateId, int Quota); -public sealed class IncreaseQuotaMutation : IMutation +public sealed class IncreaseQuotaMutation : MutationBase { public IncreaseQuotaMutation(string stateId, int amount) + : base( + CreateIntent( + operationName: "IncreaseQuota", + category: "Quota", + description: "Increase tenant quota"), + MutationContext.System("Initial quota setup") with { StateId = stateId }) { Amount = amount; - Intent = new MutationIntent - { - OperationName = "IncreaseQuota", - Category = "Quota", - Description = "Increase tenant quota" - }; - Context = MutationContext.System("Initial quota setup") with { StateId = stateId }; } public int Amount { get; } - public MutationIntent Intent { get; } - - public MutationContext Context { get; } - public MutationResult Apply(QuotaState state) => MutationResult.Success( state with { Quota = state.Quota + Amount }, @@ -60,8 +55,6 @@ public sealed class IncreaseQuotaMutation : IMutation => Amount > 0 ? ValidationResult.Success() : ValidationResult.WithError("Amount", "Amount must be positive."); - - public MutationResult Simulate(QuotaState state) => Apply(state); } public sealed class PreventNegativeQuotaPolicy : IMutationPolicy @@ -97,6 +90,7 @@ Core runtime concurrency is controlled by `MutationEngineOptions.MaxConcurrentMu ### Engine - `IMutation` +- `MutationBase` - `IMutationEngine` - `IMutationExecutor` - `MutationEngineOptions` diff --git a/src/Redis/README.md b/src/Redis/README.md index 93c60c3..7357a7a 100644 --- a/src/Redis/README.md +++ b/src/Redis/README.md @@ -1,5 +1,7 @@ ![ModularityKit.Mutator.Governance.Redis overview](../../assets/governance/providers/mutator-governance-redis-overview.png) +See [`Docs/API/Redis.md`](../../Docs/API/Redis.md) for the practical API surface and usage examples. + ## Package structure - `Configuration` for provider options diff --git a/src/Runtime/MutationEngine.cs b/src/Runtime/MutationEngine.cs index 42ab9ff..630c99e 100644 --- a/src/Runtime/MutationEngine.cs +++ b/src/Runtime/MutationEngine.cs @@ -263,6 +263,11 @@ public async Task> ExecuteBatchAsync( cancellationToken); } + public Task> ExecuteBatchAsync( + TState state, + params IMutation[] mutations) + => ExecuteBatchAsync(mutations, state); + public void RegisterPolicy(IMutationPolicy policy) => _policyRegistry.Register(policy);