diff --git a/ModularityKit.Mutator.slnx b/ModularityKit.Mutator.slnx index f184391..f83eb84 100644 --- a/ModularityKit.Mutator.slnx +++ b/ModularityKit.Mutator.slnx @@ -15,6 +15,7 @@ + diff --git a/README.md b/README.md index 9093646..dd33e54 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,27 @@ -# ModularityKit.Mutators +![ModularityKit.Mutator](assets/brand/mutator-landing-banner.png) -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![.NET](https://img.shields.io/badge/.NET-10.0-purple.svg)](https://dotnet.microsoft.com/) - -A deterministic, async safe mutation engine with built in policy enforcement, audit logging, and execution context. - ---- - -## Features +![ModularityKit.Mutator tagline](assets/brand/mutator-tagline.png) -- **Deterministic State Mutations** – Apply and simulate state changes safely -- **Policy Enforcement** – Declarative, composable mutation policies -- **Async Safe Execution** – Works across `async/await` boundaries -- **Immutable State Models** – Encourages safe, concurrent operations -- **Audit & Change Tracking** – `ChangeSet` captures granular modifications -- **High Performance** – Minimal overhead per mutation execution +# ModularityKit.Mutator ---- - -## Quick Start (Example) - -```csharp -using Microsoft.Extensions.DependencyInjection; -using ModularityKit.Mutators.Abstractions; -using ModularityKit.Mutators.Abstractions.Engine; -using ModularityKit.Mutators.Runtime; -using ModularityKit.Mutators.Runtime.Loggers; -using Mutators.Examples.BillingQuotas.Policies; -using Mutators.Examples.IamRoles.Policies; -using Mutators.Examples.WorkflowApprovals.Policies; -using IamTwoManApprovalPolicy = Mutators.Examples.IamRoles.Policies.RequireTwoManApprovalPolicy; -using FeatureFlagsTwoManApprovalPolicy = Mutators.Examples.FeatureFlags.Policies.RequireTwoManApprovalPolicy; +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![.NET](https://img.shields.io/badge/.NET-10.0-purple.svg)](https://dotnet.microsoft.com/) -var services = new ServiceCollection(); -// 1. Register Mutators engine with options -services.AddMutators(MutationEngineOptions.Strict, addDefaultLoggingInterceptor: true); +## Packages -// 2. Build DI provider -var provider = services.BuildServiceProvider(); -var engine = provider.GetRequiredService(); +- [`ModularityKit.Mutator`](src/README.md) - core mutation runtime +- [`ModularityKit.Mutator.Governance`](src/Governance/README.md) - request lifecycle, approvals, and governed execution -// 3. Register policies -engine.RegisterPolicy(new MaxQuotaPolicy()); -engine.RegisterPolicy(new PreventNegativeQuotaPolicy()); -engine.RegisterPolicy(new IamTwoManApprovalPolicy()); -engine.RegisterPolicy(new PreventLastAdminRemovalPolicy()); -engine.RegisterPolicy(new FeatureFlagsTwoManApprovalPolicy()); -engine.RegisterPolicy(new EnforceOrderPolicy()); -engine.RegisterPolicy(new RequireManagerApprovalPolicy()); +## Repository -// 4. Execute example scenarios -await Examples.FeatureFlags.Scenarios.EnableNewCheckoutScenario.Run(engine); -await Examples.BillingQuotas.Scenarios.EmergencyIncreaseScenario.Run(engine); -await Examples.IamRoles.Scenarios.GrantAdminScenario.Run(engine); +- [`Examples`](Examples/README.md) +- [`Benchmarks`](Benchmarks/README.md) +- [`Docs`](Docs/) +- [`Tests`](Tests/) -// 5. Inspect history -var history = await engine.GetHistoryAsync(stateId: "EnableNewCheckout"); -MutationHistoryLogger.LogHistory(history); +## Build -// 6. Metrics & statistics -var stats = await engine.GetStatisticsAsync(); -Console.WriteLine($"Total executed: {stats.TotalExecuted}"); -Console.WriteLine($"Average execution time: {stats.AverageExecutionTime.TotalMilliseconds:F2} ms"); +```bash +dotnet build ModularityKit.Mutator.slnx -c Release ``` - -___ -## Core Concepts - -### **Mutation** - -Represents single atomic change to specific state. - -- Implement `IMutation` -- Define intent (`MutationIntent`) -- Implement `Validate(TState)` -- Implement `Apply(TState)` and optionally `Simulate(TState)` - -___ -### **State** - -Immutable representation of domain data. - -- use `record` types. -- Concurrent safe -- Represents the source of truth for mutations - -___ -### **Policy** - -Controls which mutations are allowed. - -- Implement `IMutationPolicy` -- Evaluate mutations before application -- Return `PolicyDecision.Allow()` or `PolicyDecision.Deny(reason)` - ---- -## Best Practices - -1. **Immutable State** – Always use `record` types or read-only properties. -2. **Explicit Context** – Pass `MutationContext` per mutation. -3. **Validate Before Apply** – Call `Validate()` before applying a mutation. -4. **Enforce Policies** – Never skip policy evaluation. -5. **Scoped Execution** – Execute mutations inside a controlled engine. -6. **Do Not Share Mutable State** – Each logical operation gets its own state snapshot. -7. **Use Clear IDs for Tracking** – Helps with audit logs and debugging. -8. **Centralized Registration** – Register all policies at engine startup. - ---- -## API Reference - -### Core Interfaces - -- `IMutation` – Base interface for mutations -- `IMutationPolicy` – Policy controlling allowed mutations -- `IMutationEngine` – Engine for applying mutations -- `MutationContext` – Context carrying metadata about execution -- `MutationResult` – Wraps the new state and change set -- `ChangeSet` – Captures state modifications - -## Package Layout - -- `ModularityKit.Mutator` - core mutation runtime, policies, audit, history, and side effects -- [`ModularityKit.Mutator.Governance`](src/Governance/README.md) - governed mutation request lifecycle, pending execution, and approval-oriented contracts - ---- -## Metrics & Logging - -The engine supports: -- Mutation execution history (`GetHistoryAsync`) -- Execution statistics (`GetStatisticsAsync`) -- Logging via `MutationHistoryLogger` -- Optional interceptors for audit and diagnostics - ---- -## Architecture Decision Records (ADR) - -Key architectural decisions for **ModularityKit.Mutators** are tracked as ADRs. They document engine design, policy evaluation, context handling, change tracking, and DI registration. - -| ADR | Title | Summary | -| ------- | --------------------------- | -------------------------------------------------------------------------------------------- | -| ADR-001 | Mutation Engine Design | Defines `IMutationEngine`, engine options, strict vs lenient execution modes | -| ADR-002 | Policy Evaluation | Centralized policy evaluation for validation, risk assessment, and allow/deny decisions | -| ADR-003 | Context & MutationContext | Explicit, per execution flow context for audit, tracing, and tenant isolation | -| ADR-004 | ChangeSet Model | Immutable, granular state changes for audit, rollback, and history inspection | -| ADR-005 | Mutation Audit Abstractions | Structured, immutable audit entries capturing intent, context, changes, and policy decisions | - -See full ADR documentation in [`Docs/Decision/Adr`](Docs/Decision/listadr) for details on each architectural decision. - -## Roadmap - -The planned evolution of the engine is documented in [`Docs/Roadmap.md`](Docs/Roadmap.md). diff --git a/Tests/ModularityKit.Mutator.Tests/ModularityKit.Mutator.Tests.csproj b/Tests/ModularityKit.Mutator.Tests/ModularityKit.Mutator.Tests.csproj new file mode 100644 index 0000000..0f16982 --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/ModularityKit.Mutator.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Tests/ModularityKit.Mutator.Tests/Runtime/MutationEngineConcurrencyTests.cs b/Tests/ModularityKit.Mutator.Tests/Runtime/MutationEngineConcurrencyTests.cs new file mode 100644 index 0000000..b7705da --- /dev/null +++ b/Tests/ModularityKit.Mutator.Tests/Runtime/MutationEngineConcurrencyTests.cs @@ -0,0 +1,221 @@ +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Concurrent; +using ModularityKit.Mutator.Abstractions; +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 MutationEngineConcurrencyTests +{ + [Fact] + public async Task ExecuteAsync_serializes_mutations_that_target_the_same_state_id() + { + var services = new ServiceCollection(); + services.AddMutators(configure: options => + { + options.MaxConcurrentMutations = 4; + options.EnableDetailedMetrics = false; + }); + + await using var provider = services.BuildServiceProvider(); + var engine = provider.GetRequiredService(); + using var gate = new BlockingMutationGate(); + var state = new OrderedState("initial"); + + var first = new BlockingMutation(gate, "shared-state", "first"); + var second = new BlockingMutation(gate, "shared-state", "second"); + + var firstTask = Task.Run(() => engine.ExecuteAsync(first, state)); + var secondTask = Task.Run(() => engine.ExecuteAsync(second, state)); + + Assert.True(await gate.WaitForEntriesAsync(1, TimeSpan.FromSeconds(5))); + Assert.Equal(1, gate.PeakConcurrency); + + gate.Release(); + + var results = await Task.WhenAll(firstTask, secondTask); + + Assert.All(results, result => Assert.True(result.IsSuccess)); + Assert.Equal(1, gate.PeakConcurrency); + } + + [Fact] + public async Task ExecuteAsync_honors_max_concurrent_mutations_for_different_states() + { + var services = new ServiceCollection(); + services.AddMutators(configure: options => + { + options.MaxConcurrentMutations = 2; + options.EnableDetailedMetrics = false; + }); + + await using var provider = services.BuildServiceProvider(); + var engine = provider.GetRequiredService(); + using var gate = new BlockingMutationGate(); + var states = new[] + { + new OrderedState("one"), + new OrderedState("two"), + new OrderedState("three"), + new OrderedState("four") + }; + + var tasks = new[] + { + Task.Run(() => engine.ExecuteAsync(new BlockingMutation(gate, "state-1", "one"), states[0])), + Task.Run(() => engine.ExecuteAsync(new BlockingMutation(gate, "state-2", "two"), states[1])), + Task.Run(() => engine.ExecuteAsync(new BlockingMutation(gate, "state-3", "three"), states[2])), + Task.Run(() => engine.ExecuteAsync(new BlockingMutation(gate, "state-4", "four"), states[3])) + }; + + Assert.True(await gate.WaitForEntriesAsync(2, TimeSpan.FromSeconds(5))); + Assert.Equal(2, gate.PeakConcurrency); + + gate.Release(); + + var results = await Task.WhenAll(tasks); + + Assert.All(results, result => Assert.True(result.IsSuccess)); + Assert.Equal(2, gate.PeakConcurrency); + } + + [Fact] + public async Task ExecuteBatchAsync_remains_ordered_while_respecting_runtime_concurrency_gates() + { + var services = new ServiceCollection(); + services.AddMutators(configure: options => + { + options.MaxConcurrentMutations = 2; + options.EnableDetailedMetrics = false; + }); + + await using var provider = services.BuildServiceProvider(); + var engine = provider.GetRequiredService(); + var observed = new ConcurrentQueue(); + + var batch = new[] + { + new OrderedMutation("state-1", "first", observed), + new OrderedMutation("state-2", "second", observed), + new OrderedMutation("state-1", "third", observed) + }; + + var result = await engine.ExecuteBatchAsync(batch, new OrderedState("initial")); + + Assert.True(result.IsSuccess); + Assert.Equal(3, result.Results.Count); + Assert.Equal(new[] { "first", "second", "third" }, observed); + } + + [Fact] + public void AddMutators_rejects_non_positive_max_concurrent_mutations() + { + var services = new ServiceCollection(); + services.AddMutators(configure: options => options.MaxConcurrentMutations = 0); + + Assert.Throws(() => services.BuildServiceProvider().GetRequiredService()); + } + + private sealed record OrderedState(string Value); + + private sealed class OrderedMutation(string stateId, string value, ConcurrentQueue observed) + : IMutation + { + public MutationIntent Intent { get; } = new() + { + OperationName = "Order", + Category = "Test", + Description = "Observe execution order" + }; + + public MutationContext Context { get; } = MutationContext.User("tester", "Tester", "Order test") + with { StateId = stateId }; + + public MutationResult Apply(OrderedState state) + { + observed.Enqueue(value); + return MutationResult.Success(state with { Value = value }, ChangeSet.Empty); + } + + public ValidationResult Validate(OrderedState state) => ValidationResult.Success(); + + public MutationResult Simulate(OrderedState state) => Apply(state); + } + + private sealed class BlockingMutationGate : IDisposable + { + private readonly ManualResetEventSlim _release = new(false); + private int _entered; + private int _active; + private int _peak; + + public int PeakConcurrency => Volatile.Read(ref _peak); + + public async Task WaitForEntriesAsync(int expectedEntries, TimeSpan timeout) + { + var started = DateTimeOffset.UtcNow; + + while (Volatile.Read(ref _entered) < expectedEntries) + { + if (DateTimeOffset.UtcNow - started > timeout) + return false; + + await Task.Delay(10); + } + + return true; + } + + public void Enter() + { + Interlocked.Increment(ref _entered); + var active = Interlocked.Increment(ref _active); + + while (true) + { + var peak = Volatile.Read(ref _peak); + if (active <= peak || Interlocked.CompareExchange(ref _peak, active, peak) == peak) + break; + } + + _release.Wait(); + Interlocked.Decrement(ref _active); + } + + public void Release() => _release.Set(); + + public void Dispose() => _release.Dispose(); + } + + private sealed class BlockingMutation( + BlockingMutationGate gate, + string stateId, + string value) : IMutation + { + public MutationIntent Intent { get; } = new() + { + OperationName = "Block", + Category = "Test", + Description = "Block until released" + }; + + public MutationContext Context { get; } = MutationContext.User($"{stateId}-actor", $"{stateId}-actor", "Concurrency test") + with { StateId = stateId }; + + public MutationResult Apply(OrderedState state) + { + gate.Enter(); + return MutationResult.Success(state with { Value = value }, ChangeSet.Empty); + } + + public ValidationResult Validate(OrderedState state) => ValidationResult.Success(); + + public MutationResult Simulate(OrderedState state) => Apply(state); + } +} diff --git a/assets/brand/logo.png b/assets/brand/logo.png new file mode 100644 index 0000000..77c54e9 Binary files /dev/null and b/assets/brand/logo.png differ diff --git a/assets/brand/logotype.png b/assets/brand/logotype.png new file mode 100644 index 0000000..94da0fb Binary files /dev/null and b/assets/brand/logotype.png differ diff --git a/assets/brand/modularitykit-mutator-logo-128.png b/assets/brand/modularitykit-mutator-logo-128.png new file mode 100644 index 0000000..44c912f Binary files /dev/null and b/assets/brand/modularitykit-mutator-logo-128.png differ diff --git a/assets/brand/mutator-landing-banner.png b/assets/brand/mutator-landing-banner.png new file mode 100644 index 0000000..5f294ec Binary files /dev/null and b/assets/brand/mutator-landing-banner.png differ diff --git a/assets/brand/mutator-tagline.png b/assets/brand/mutator-tagline.png new file mode 100644 index 0000000..b80530e Binary files /dev/null and b/assets/brand/mutator-tagline.png differ diff --git a/assets/core/mutator-core-what-it-provides.png b/assets/core/mutator-core-what-it-provides.png new file mode 100644 index 0000000..f1e4de1 Binary files /dev/null and b/assets/core/mutator-core-what-it-provides.png differ diff --git a/assets/core/mutator-overview.png b/assets/core/mutator-overview.png new file mode 100644 index 0000000..c6b4bf1 Binary files /dev/null and b/assets/core/mutator-overview.png differ diff --git a/assets/governance/goverwhatadded.png b/assets/governance/goverwhatadded.png new file mode 100644 index 0000000..e4ebb08 Binary files /dev/null and b/assets/governance/goverwhatadded.png differ diff --git a/assets/governance/logogorver.png b/assets/governance/logogorver.png new file mode 100644 index 0000000..2fdb59e Binary files /dev/null and b/assets/governance/logogorver.png differ diff --git a/assets/governance/logotype_governance.png b/assets/governance/logotype_governance.png new file mode 100644 index 0000000..f329f44 Binary files /dev/null and b/assets/governance/logotype_governance.png differ diff --git a/assets/governance/modularitykit-mutator-governance-logo-128.png b/assets/governance/modularitykit-mutator-governance-logo-128.png new file mode 100644 index 0000000..9ea4bef Binary files /dev/null and b/assets/governance/modularitykit-mutator-governance-logo-128.png differ diff --git a/assets/governance/mutator-governance-overview.png b/assets/governance/mutator-governance-overview.png new file mode 100644 index 0000000..18e21e7 Binary files /dev/null and b/assets/governance/mutator-governance-overview.png differ diff --git a/src/Abstractions/Engine/IMutationEngine.cs b/src/Abstractions/Engine/IMutationEngine.cs index 7e67548..13315c6 100644 --- a/src/Abstractions/Engine/IMutationEngine.cs +++ b/src/Abstractions/Engine/IMutationEngine.cs @@ -25,6 +25,12 @@ namespace ModularityKit.Mutator.Abstractions.Engine; /// History persistence /// /// +/// Core runtime concurrency is governed by . +/// Mutations that target the same are serialized by the runtime so shared-state workloads +/// remain deterministic. This is separate from governance request storage concurrency, which protects request lifecycle writes +/// in the governance package. +/// +/// /// The engine acts as the primary governance boundary for all state mutations. /// /// @@ -57,8 +63,9 @@ Task> ExecuteAsync( /// A describing the outcome of the batch execution. /// /// - /// Batch execution semantics (e.g. fail-fast vs best-effort) are controlled - /// by . + /// Batch execution is ordered and sequential. Each batch step passes through the same core concurrency controls as a + /// single execution, including the maximum concurrent execution limit and any state-specific serialization. + /// Fail-fast vs best-effort behavior is controlled by . /// Task> ExecuteBatchAsync( IEnumerable> mutations, diff --git a/src/Abstractions/MutationEngineOptions.cs b/src/Abstractions/MutationEngineOptions.cs index f3c888d..f2ff384 100644 --- a/src/Abstractions/MutationEngineOptions.cs +++ b/src/Abstractions/MutationEngineOptions.cs @@ -55,11 +55,14 @@ public sealed class MutationEngineOptions public bool EnableDetailedMetrics { get; set; } = false; /// - /// The maximum number of mutations that may be executed concurrently. + /// The maximum number of mutations that may be executed concurrently by the core runtime. /// /// - /// This setting controls parallelism and can be used to limit resource usage - /// or avoid contention. + /// This setting limits concurrent core execution across the engine. + /// Mutations that carry the same + /// are serialized so shared-state workloads remain deterministic. + /// Batch execution remains ordered; the limit applies to each batch step as it + /// passes through the runtime. /// public int MaxConcurrentMutations { get; set; } = 10; diff --git a/src/Governance/README.md b/src/Governance/README.md index 76e43ef..6b15921 100644 --- a/src/Governance/README.md +++ b/src/Governance/README.md @@ -1,38 +1,42 @@ -# ModularityKit.Mutator.Governance +![ModularityKit.Mutator.Governance](../../assets/governance/mutator-governance-overview.png) + +![What it adds and governance lifecycle](../../assets/governance/goverwhatadded.png) + +## Quick start + +```csharp +using ModularityKit.Mutator.Abstractions.Core; +using ModularityKit.Mutator.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Storage; +using ModularityKit.Mutator.Governance.Runtime.Storage; + +var store = new InMemoryMutationRequestStore(); + +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") + ]); + +var persisted = await store.Create(request); +Console.WriteLine($"{persisted.RequestId} -> {persisted.Status}"); +``` + +## Primary APIs -`ModularityKit.Mutator.Governance` is the governance focused extension layer for `ModularityKit.Mutator`. - -The core package stays responsible for direct mutation execution. Governance builds on top of that runtime with request based lifecycle concepts such as deferred execution, approvals, and request storage. - -## Features - -- **Mutation Requests** - model governed mutation submission as a durable request -- **Pending Lifecycle** - represent requests that cannot execute immediately -- **Decision History** - record approvals, rejections, cancellations, and other lifecycle transitions -- **Approval Workflow** - model request-level approval requirements and explicit approver actions -- **Governed Execution** - execute approved requests through resolution and the core mutation engine -- **Request Storage Contracts** - define a persistence seam for governance-oriented stores -- **Runtime Lifecycle Management** - move requests through pending, approval, expiration, and execution transitions -- **In-Memory Runtime Support** - provide lightweight request runtime services for development and tests - -## Governance Flow - -The package is built around a request-driven governance loop: - -1. create `MutationRequest` -2. move it through pending lifecycle states when direct execution is not allowed -3. collect approval decisions when approval is required -4. resolve the request against the current state version before execution -5. execute the underlying mutation through the core engine -6. persist the terminal governance outcome and execution metadata - -The important point is that governance owns the request lifecycle around execution. The base `ModularityKit.Mutator` package still owns the mutation engine itself. - -## Main Entry Points - -Most consumers only need a small set of types. - -### Request Model +### Requests - `MutationRequest` - `MutationRequestFactory` @@ -40,31 +44,23 @@ Most consumers only need a small set of types. - `MutationRequestStatus` - `PendingMutationReason` -Use these to create and inspect governed requests. - ### Storage - `IMutationRequestStore` - `InMemoryMutationRequestStore` -Use the store to persist requests and load them back into governance runtime services. - ### Lifecycle - `IMutationRequestLifecycleManager` - `MutationRequestLifecycleManager` -Use lifecycle services to submit, pend, approve, reject, expire, supersede, cancel, and mark requests as executed. - ### Approval - `IMutationRequestApprovalWorkflowManager` - `MutationRequestApprovalWorkflowManager` - `MutationApprovalRequirement` -Use approval workflow services when a request must be explicitly approved by one or more actors before execution. - -### Version Resolution +### Resolution - `IMutationRequestVersionResolver` - `IMutationRequestVersionResolutionManager` @@ -72,128 +68,53 @@ Use approval workflow services when a request must be explicitly approved by one - `MutationRequestVersionResolutionOutcome` - `VersionedRequestResolutionStrategy` -Use resolution services to decide what happens when deferred request no longer matches the state version it was created against. - -### Governed Execution +### Execution - `IGovernanceExecutionManager` - `GovernanceExecutionManager` - `GovernedExecutionResult` -Use governed execution to close the loop from approved request to core mutation execution and terminal governance state. +## Package structure -## Package Areas +The project is organized by governance concern: -The codebase is organized by governance concern rather than by framework layer alone. +- `Abstractions/Requests` for request models, decisions, and factory methods +- `Abstractions/Storage` for persistence contracts +- `Abstractions/Approval` for approval requirements and workflow contracts +- `Abstractions/Resolution` for stale-version handling and resolution outcomes +- `Abstractions/Execution` for governed execution contracts and results +- `Runtime` for lifecycle, approval, resolution, execution, and in-memory storage services +- `Abstractions/Exceptions` for governance-specific failures -### Requests - -`Abstractions/Requests` contains the durable request model, decision taxonomy, and request factory methods. - -- `Requests/Model` -- `Requests/Decisions` -- `Requests/Factory` +## Examples -### Lifecycle +Runnable examples live under [`Examples/Governance`](../../Examples/Governance): -`Lifecycle` owns generic request movement between governance states such as pending, approved, rejected, expired, superseded, and executed. +- [`RequestLifecycle`](../../Examples/Governance/RequestLifecycle/README.md) +- [`ApprovalWorkflow`](../../Examples/Governance/ApprovalWorkflow/README.md) +- [`VersionedResolution`](../../Examples/Governance/VersionedResolution/README.md) +- [`GovernedExecution`](../../Examples/Governance/GovernedExecution/README.md) -- `Lifecycle/Contracts` -- `Lifecycle/Model` -- `Runtime/Lifecycle/Execution` -- `Runtime/Lifecycle/Validation` -- `Runtime/Lifecycle/State` +## Relationship to the core package -### Approval +`ModularityKit.Mutator` owns mutation execution, policy evaluation, audit, history, side effects, and metrics. -`Approval` builds request-level approval workflow on top of the generic lifecycle model. +`ModularityKit.Mutator.Governance` owns the request lifecycle around that execution: approvals, pending states, request storage, stale-version resolution, and terminal governance decisions. -- `Approval/Contracts` -- `Approval/Model` -- `Approval/Mapping` -- `Runtime/Approval/Execution` -- `Runtime/Approval/State` +## Current scope -### Resolution +Included today: -`Resolution` owns version-aware request handling before governed execution. - -- `Resolution/Contracts` -- `Resolution/Model` -- `Resolution/Strategies` -- `Runtime/Resolution/Evaluation` -- `Runtime/Resolution/Execution` - -### Execution - -`Execution` owns the bridge from governance request semantics into the core mutation engine. - -- `Execution/Contracts` -- `Execution/Model` -- `Runtime/Execution/Mutation` -- `Runtime/Execution/Orchestration` -- `Runtime/Execution/Outcome` -- `Runtime/Execution/Persistence` - -### Storage and Exceptions - -`Storage` defines persistence seams. `Exceptions` contains governance-specific failures grouped by concern. - -- `Abstractions/Storage` -- `Abstractions/Exceptions/Approval` -- `Abstractions/Exceptions/Lifecycle` -- `Abstractions/Exceptions/Storage` - -## What Exists Today - -Today the package already provides: - -- durable `MutationRequest` modeling -- request-level approval requirements +- request modeling and decision history +- approval requirements and workflow execution - optimistic concurrency in request storage -- explicit lifecycle transitions -- version-aware request resolution -- governed execution through the core mutation engine -- in-memory runtime support for examples and tests - -What it does not try to do yet: - -- persistence providers such as EF Core or PostgreSQL -- query stores for operational governance reporting -- compensation and retry orchestration -- external async approval or policy integrations - -## Relationship to Core - -### `ModularityKit.Mutator` - -Responsible for: - -- mutation execution -- policy evaluation -- audit and history basics -- side effects -- metrics and interception - -### `ModularityKit.Mutator.Governance` - -Responsible for: - -- mutation request lifecycle -- pending execution modeling -- approval oriented governance contracts -- request decision history -- governance specific storage and future query seams - -## Direction - -This package is the place where broader governance behavior should grow without turning the core mutation engine into a workflow framework. - -The near-term direction is: +- version-aware resolution before execution +- governed execution orchestration +- in-memory support for local runtime scenarios -- harden governed execution semantics -- add governance persistence and query providers -- expose governance metadata operationally -- support richer approval and integration scenarios +Not included yet: -The goal is to keep the core runtime small and execution focused while letting governance evolve as an opt-in extension. +- production persistence providers such as EF Core or PostgreSQL +- reporting/query stores for operational governance views +- compensation or retry orchestration +- external approval system integrations diff --git a/src/ModularityKit.Mutator.Governance.csproj b/src/ModularityKit.Mutator.Governance.csproj index 94484ae..fd73c51 100644 --- a/src/ModularityKit.Mutator.Governance.csproj +++ b/src/ModularityKit.Mutator.Governance.csproj @@ -17,6 +17,7 @@ MIT governance;approvals;requests;workflow;audit README.md + modularitykit-mutator-governance-logo-128.png true true @@ -31,6 +32,11 @@ + + + + + diff --git a/src/ModularityKit.Mutator.csproj b/src/ModularityKit.Mutator.csproj index 2bcef58..1ed350b 100644 --- a/src/ModularityKit.Mutator.csproj +++ b/src/ModularityKit.Mutator.csproj @@ -15,6 +15,7 @@ MIT mutations;policy;audit;history;governance README.md + modularitykit-mutator-logo-128.png true true @@ -28,7 +29,11 @@ - + + + + + diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..0ccf497 --- /dev/null +++ b/src/README.md @@ -0,0 +1,166 @@ +![ModularityKit.Mutator](../assets/core/mutator-overview.png) + +![What it provides](../assets/core/mutator-core-what-it-provides.png) + +## Quick start + +```csharp +using Microsoft.Extensions.DependencyInjection; +using ModularityKit.Mutator.Abstractions; +using ModularityKit.Mutator.Abstractions.Changes; +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.Runtime; + +var services = new ServiceCollection(); +services.AddMutators(MutationEngineOptions.Strict); + +var provider = services.BuildServiceProvider(); +var engine = provider.GetRequiredService(); + +engine.RegisterPolicy(new PreventNegativeQuotaPolicy()); + +var state = new QuotaState("tenant-42", 10); +var mutation = new IncreaseQuotaMutation("tenant-42", 5); + +var result = await engine.ExecuteAsync(mutation, state); +Console.WriteLine(result.NewState!.Quota); + +public sealed record QuotaState(string StateId, int Quota); + +public sealed class IncreaseQuotaMutation : IMutation +{ + public IncreaseQuotaMutation(string stateId, int amount) + { + 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 }, + ChangeSet.Single(StateChange.Modified("Quota", state.Quota, state.Quota + Amount))); + + public ValidationResult Validate(QuotaState state) + => Amount > 0 + ? ValidationResult.Success() + : ValidationResult.WithError("Amount", "Amount must be positive."); + + public MutationResult Simulate(QuotaState state) => Apply(state); +} + +public sealed class PreventNegativeQuotaPolicy : IMutationPolicy +{ + public string Name => "PreventNegativeQuota"; + public int Priority => 100; + public string? Description => "Rejects quota changes that would go negative."; + + public PolicyDecision Evaluate(IMutation mutation, QuotaState state) + => PolicyDecision.Allow(); +} +``` + +## Execution model + +`IMutationEngine` runs a consistent pipeline around every mutation: + +1. evaluate registered policies +2. validate the mutation against current state +3. run interceptors +4. apply the mutation +5. record audit/history data +6. update runtime metrics + +Core runtime concurrency is controlled by `MutationEngineOptions.MaxConcurrentMutations`. + +- mutations targeting the same `MutationContext.StateId` are serialized +- batch execution remains ordered and sequential +- this is separate from request-storage concurrency in `ModularityKit.Mutator.Governance` + +## Primary APIs + +### Engine + +- `IMutation` +- `IMutationEngine` +- `IMutationExecutor` +- `MutationEngineOptions` + +### Policies + +- `IMutationPolicy` +- `IPolicyRegistry` +- `PolicyDecision` +- `PolicyRequirement` + +### Results and changes + +- `MutationResult` +- `BatchMutationResult` +- `ValidationResult` +- `ChangeSet` +- `StateChange` + +### Context and intent + +- `MutationContext` +- `MutationIntent` +- `BlastRadius` + +### Runtime observability + +- `IMutationAuditor` +- `IMutationHistoryStore` +- `MutationHistory` +- `IMetricsCollector` +- `MutationStatistics` +- `IMutationInterceptor` + +## Examples + +Runnable examples for the core engine live under [`Examples/Core`](../Examples/Core): + +- [`BillingQuotas`](../Examples/Core/BillingQuotas/README.md) +- [`FeatureFlags`](../Examples/Core/FeatureFlags/README.md) +- [`IamRoles`](../Examples/Core/IamRoles/README.md) +- [`WorkflowApprovals`](../Examples/Core/WorkflowApprovals/README.md) + +## Relationship to governance + +`ModularityKit.Mutator` is the direct execution runtime. + +If your workflow needs deferred execution, request approval, pending states, or stale-version resolution before the mutation can run, use [`ModularityKit.Mutator.Governance`](Governance/README.md) on top of the core package. + +## Current scope + +Included today: + +- direct mutation execution +- batch execution +- policy evaluation +- validation and failure modeling +- audit/history capture +- metrics and interception +- in-memory runtime components for local and test scenarios + +Not included in the core package: + +- request lifecycle management +- approval workflow orchestration +- versioned request resolution +- governed request persistence contracts diff --git a/src/Runtime/Internal/MutationExecutionConcurrencyGate.cs b/src/Runtime/Internal/MutationExecutionConcurrencyGate.cs new file mode 100644 index 0000000..1bfb7fe --- /dev/null +++ b/src/Runtime/Internal/MutationExecutionConcurrencyGate.cs @@ -0,0 +1,57 @@ +using System.Collections.Concurrent; + +namespace ModularityKit.Mutator.Runtime.Internal; + +/// +/// Coordinates core mutation execution concurrency across the engine. +/// +internal sealed class MutationExecutionConcurrencyGate(int maxConcurrentMutations) +{ + private readonly SemaphoreSlim _globalGate = new(maxConcurrentMutations, maxConcurrentMutations); + private readonly ConcurrentDictionary _stateGates = new(StringComparer.Ordinal); + + public async ValueTask EnterAsync(string? stateId, CancellationToken cancellationToken) + { + await _globalGate.WaitAsync(cancellationToken).ConfigureAwait(false); + + var stateGate = default(SemaphoreSlim); + + try + { + if (!string.IsNullOrWhiteSpace(stateId)) + { + stateGate = _stateGates.GetOrAdd(stateId, static _ => new SemaphoreSlim(1, 1)); + await stateGate.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return new Lease(_globalGate, stateGate); + } + catch + { + _globalGate.Release(); + throw; + } + } + + /// + /// Represents an acquired execution slot. + /// + internal readonly struct Lease : IAsyncDisposable + { + private readonly SemaphoreSlim _globalGate; + private readonly SemaphoreSlim? _stateGate; + + public Lease(SemaphoreSlim globalGate, SemaphoreSlim? stateGate) + { + _globalGate = globalGate; + _stateGate = stateGate; + } + + public ValueTask DisposeAsync() + { + _stateGate?.Release(); + _globalGate.Release(); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/Runtime/MutationEngine.cs b/src/Runtime/MutationEngine.cs index 83ab80a..42ab9ff 100644 --- a/src/Runtime/MutationEngine.cs +++ b/src/Runtime/MutationEngine.cs @@ -32,6 +32,7 @@ internal sealed class MutationEngine( private readonly IMutationHistoryStore _historyStore = historyStore ?? throw new ArgumentNullException(nameof(historyStore)); private readonly IMetricsCollector _metricsCollector = metricsCollector ?? throw new ArgumentNullException(nameof(metricsCollector)); private readonly MutationEngineOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + private readonly MutationExecutionConcurrencyGate _concurrencyGate = CreateConcurrencyGate(options); public async Task> ExecuteAsync( IMutation mutation, @@ -42,9 +43,13 @@ public async Task> ExecuteAsync( var stopwatch = Stopwatch.StartNew(); IMetricsScope? metricsScope = null; + await using var executionLease = await _concurrencyGate + .EnterAsync(mutation.Context.StateId, cancellationToken) + .ConfigureAwait(false); + if (_options.EnableDetailedMetrics) metricsScope = _metricsCollector.BeginScope(executionId); - + try { return await ExecutePipelineAsync( @@ -374,4 +379,17 @@ private async Task StoreInHistoryAsync( await _historyStore.StoreAsync(entry, cancellationToken); } + + private static MutationExecutionConcurrencyGate CreateConcurrencyGate(MutationEngineOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (options.MaxConcurrentMutations < 1) + throw new ArgumentOutOfRangeException( + nameof(options), + options.MaxConcurrentMutations, + "MaxConcurrentMutations must be greater than zero."); + + return new MutationExecutionConcurrencyGate(options.MaxConcurrentMutations); + } }