diff --git a/Docs/Decision/Adr/ADR_027_Governed_Execution_Manager.md b/Docs/Decision/Adr/ADR_027_Governed_Execution_Manager.md new file mode 100644 index 0000000..be5cf34 --- /dev/null +++ b/Docs/Decision/Adr/ADR_027_Governed_Execution_Manager.md @@ -0,0 +1,99 @@ +# ADR-027: Governed Execution Manager + +## Tag +#adr_027 + +## Status +Accepted + +## Date +2026-06-24 + +## Scope +ModularityKit.Mutator.Governance + +## Context + +The governance package already models: + +- durable mutation requests +- pending request lifecycle +- approval workflow +- version-aware request resolution + +What remained open was the final governed execution loop: + +- load an approved request +- resolve it against the current state version +- execute the underlying core mutation only when resolution permits it +- persist the terminal governance outcome +- record the resulting state version for future audit and replay + +Without an explicit runtime service for that flow, callers would have to manually compose: + +- request loading +- resolution +- mutation engine invocation +- request status persistence +- execution decision history + +That would make governed execution easy to implement inconsistently across applications. + +## Decision + +The governance package introduces a dedicated governed execution runtime centered on `IGovernanceExecutionManager`. + +The governed execution flow is: + +1. load and resolve an approved request through governance version resolution +2. stop early when resolution yields a non-executable outcome such as: + - `RejectedAsStale` + - `RequiresRenewedApproval` +3. execute the wrapped core mutation through `IMutationEngine` only when resolution allows execution +4. persist the terminal governance request outcome: + - `Executed` on successful mutation execution + - `Rejected` on failed execution or execution exception +5. record governance metadata such as: + - resulting state version + - executed timestamp + - request correlation metadata flowing into core audit/history + +The runtime is intentionally split by responsibility: + +- `GovernanceExecutionManager` orchestrates the end-to-end flow +- `GovernedMutation` carries governance request identifiers into core execution metadata +- `GovernedExecutionOutcomeHandler` maps execution outcomes into terminal request state +- `GovernedExecutionDecisionFactory` creates execution-related governance decisions +- `GovernedExecutionRequestPersistence` performs guarded request persistence with optimistic concurrency + +## Design Rationale + +- Governed execution is governance concern, not responsibility of the base mutation engine. +- Version resolution must always happen before governed execution proceeds. +- Terminal request persistence must be handled consistently and with optimistic concurrency checks. +- The execution runtime should remain decomposed so orchestration, persistence, and decision mapping can evolve independently. + +## Consequences + +### Positive + +- Governance now closes the loop from approved request to terminal execution outcome. +- Version drift handling is enforced before core execution starts. +- Core audit/history can be correlated with governance request identifiers. +- Execution state is recorded explicitly through `ExecutedAt` and `ResultingStateVersion`. + +### Negative + +- Governance runtime now owns a larger orchestration surface. +- Execution failures currently collapse into terminal rejection and may require richer taxonomy later. +- Future work is still needed for: + - governed execution retries + - compensation semantics + - persistent queryable execution history across providers + +## Related ADRs + +- ADR-020: Governance MutationRequest Model +- ADR-023: Governance Versioned Request Resolution +- ADR-024: Governance Runtime Pending Request Handling +- ADR-025: Governance Approval Workflow diff --git a/Docs/Decision/listadr.md b/Docs/Decision/listadr.md index 8c04d7b..83a3692 100644 --- a/Docs/Decision/listadr.md +++ b/Docs/Decision/listadr.md @@ -42,5 +42,6 @@ These ADRs describe the `ModularityKit.Mutator.Governance` extension layer and i | ADR-024 | Governance Runtime Pending Request Handling | [ADR-024](Adr/ADR_024_Governance_Runtime_Pending_Request_Handling.md) | | ADR-025 | Governance Approval Workflow | [ADR-025](Adr/ADR_025_Governance_Approval_Workflow.md) | | ADR-026 | Governance Request Query API | [ADR-026](Adr/ADR_026_Governance_Request_Query_API.md) | +| ADR-027 | Governed Execution Manager | [ADR-027](Adr/ADR_027_Governed_Execution_Manager.md) | > See individual ADRs for detailed context, decision rationale, and consequences. diff --git a/Docs/ExecutionModel.md b/Docs/ExecutionModel.md index 6fe96f9..6e5d7c8 100644 --- a/Docs/ExecutionModel.md +++ b/Docs/ExecutionModel.md @@ -2,7 +2,7 @@ This document describes the execution model that `ModularityKit.Mutator` is moving toward as governance features become first-class runtime capabilities. -It complements the roadmap by explaining the lifecycle of a mutation once the engine supports pending execution, approvals, versioning, and compensation. +It complements the roadmap by explaining the lifecycle of a mutation now that governance already has request lifecycle, approval workflow, and version-resolution primitives, but still needs a fully closed governed execution loop. ## Current Model @@ -18,7 +18,7 @@ Today, the engine is centered around direct mutation execution: This model is strong for immediate execution flows, but it is intentionally narrow: - blocked mutations are terminal outcomes -- approval requirements are modeled but not yet lifecycle-driven +- direct mutation execution is still the dominant runtime path - concurrency is not yet part of a first-class execution contract - compensation and re-execution are not yet modeled as governed transitions @@ -32,11 +32,12 @@ The target shape is: 2. Evaluate policies and requirements 3. Enter pending state when execution is deferred 4. Resolve requirements through approvals or external checks -5. Re-validate against the current state version -6. Execute or reject +5. Resolve against the current state version +6. Execute through a governed execution manager 7. Emit side effects 8. Audit and persist history -9. Optionally compensate or reverse +9. Record executed or terminal governance decision +10. Optionally compensate or reverse This is the key conceptual shift in the project: @@ -69,7 +70,7 @@ Possible pending reasons: - `PendingDependency` - `PendingQuota` -Pending state should be treated as a first-class runtime state, not just a flag in `MutationResult`. +Pending state is already a first-class governance state. The remaining work is to ensure every pending path has a clear re-entry route into governed execution. ### Resolution @@ -83,6 +84,8 @@ Possible outcomes: - expired - superseded by a newer request or state version +Resolution is already modeled in the governance runtime. The remaining gap is making it part of one mandatory execution path rather than a helper that callers compose manually. + ### Versioned Execution Approval and deferred execution require explicit version handling. @@ -95,6 +98,23 @@ When a request is approved against a later state than the one originally evaluat This behavior must be explicit and consistent across all pending mutation types. +The main hardening point is no longer whether version resolution exists, but whether approved requests always pass through it before execution and whether revalidation has its own explicit runtime semantics. + +### Governed Execution Manager + +Once approvals and version resolution exist, the runtime needs one orchestrator that closes the loop. + +Expected responsibilities: + +- load an approved request +- perform mandatory version resolution +- choose execute / reject / re-approve / revalidate paths +- invoke the core mutation engine when execution is still valid +- record resulting state version and execution outcome +- persist terminal governance decisions such as `Executed` + +Without this step, governance remains a set of useful runtime pieces rather than one coherent execution contract. + ### Compensation Once the engine supports governed execution over time, compensation becomes part of the execution model rather than a simple utility. @@ -123,18 +143,20 @@ Without an explicit execution model, these features risk being implemented as is ## Design Pressure Points -These are the architectural questions that should stay visible as implementation starts: +These are the architectural questions that should stay visible as implementation continues: - Is the primary unit of governance a mutation or a mutation request? - What state version contract is required for deferred execution? - When does a pending mutation become stale? - Can approvals survive state drift, or must they be renewed? +- What is the exact contract of revalidation before execution? - Are side effects emitted on request creation, on execution, or both? - How are compensation flows represented in audit and history? +- Where does core mutation execution concurrency end and governance request concurrency begin? ## Relationship to the Roadmap -- `v1.1 Governance Runtime` introduces pending mutation lifecycle, approval workflow, versioned execution, and concurrency control. +- `v1.1 Governance Runtime Hardening` closes the governed execution loop and hardens approval, version-resolution, and core concurrency semantics. - `v1.2 Governance Data` adds persistence, queryability, metadata handling, and typed side effects around that runtime model. - `v1.3 Integration` expands the model to async policy evaluation and external governance dependencies. - `v2.0 Governance Platform` extends the lifecycle with compensation and richer policy composition. diff --git a/Docs/Roadmap.md b/Docs/Roadmap.md index 083d332..b2724fe 100644 --- a/Docs/Roadmap.md +++ b/Docs/Roadmap.md @@ -2,7 +2,7 @@ This document captures the most valuable next steps for `ModularityKit.Mutator` based on the current state of the API, runtime, and examples. -The goal is not to add features for their own sake. The goal is to close gaps between the public model and the runtime behavior, then extend the engine into a more complete governance platform for state mutations. +The goal is not to add features for their own sake. The goal is to close gaps between the public model and the runtime behavior, then complete the governed execution loop and extend the engine into a more operational governance platform for state mutations. ## Principles @@ -15,102 +15,120 @@ The goal is not to add features for their own sake. The goal is to close gaps be These areas already exist in the model but are only partially realized in the runtime: -- `PolicyRequirement` exists, but there is no approval lifecycle around it. +- Governance now has request lifecycle, approval workflow, and version resolution primitives, but the end-to-end execution loop is still incomplete. - `MutationIntent.IsReversible` exists, but there is no undo or compensation mechanism. -- `MutationEngineOptions.MaxConcurrentMutations` exists, but concurrency control is not enforced in runtime execution. +- `MutationEngineOptions.MaxConcurrentMutations` exists, but concurrency control is not enforced in core mutation execution. - `MutationIntent.EstimatedBlastRadius`, `Tags`, and `Metadata` exist, but the engine does not yet use them for governance or query workflows. -- The project has examples and benchmarks, but no dedicated test project yet. +- Approved governance requests do not yet flow through a single runtime path that performs version resolution, mutation execution, audit/history persistence, and terminal executed-state recording. -## v1.1 Governance Runtime +## v1.1 Governance Runtime Hardening -Focus: establish a first-class governance runtime centered around mutation requests, pending execution, and version-aware decision making. +Focus: close the governed execution loop and harden the current governance runtime rather than introducing the first lifecycle concepts. -### 1. Pending Mutation Lifecycle +### 1. Governed Execution Manager -Add a first-class lifecycle around deferred mutation requests. +Add a first-class runtime path that executes approved governance requests end to end. Scope: -- Introduce a `PendingMutation` or `MutationRequest` model for governed execution. -- Support pending reasons beyond approval, such as external checks, scheduling, or dependencies. -- Support approval expiration and explicit cancellation. -- Persist approval decisions and approval history. -- Define re-execution rules when a pending mutation is resolved against a newer state version. -- Support listing and resolving pending mutation records. +- Introduce a `GovernanceExecutionManager` or equivalent orchestration runtime for approved requests. +- Always perform version resolution before executing an approved request. +- Execute the underlying mutation through the core runtime once the request is still valid. +- Record execution outcome, resulting state version, and terminal request decision. +- Mark requests as `Executed`, rejected as stale, or sent back into a renewed approval / revalidation path. +- Ensure audit and history flows capture both request-level and execution-level outcomes. Why this matters: -- Approval workflow is only one specialization of pending execution. -- A single `PendingApproval` status in `MutationResult` is not enough once the engine owns a real governance process. +- Governance runtime already knows how to hold, approve, reject, expire, and version-check requests. +- The biggest missing behavior is the actual transition from approved request to governed mutation execution. -### 2. Approval Workflow for `PolicyRequirement` +### 2. Approval Workflow Hardening -Add first-class support for approval-based policy outcomes. +Harden the approval model now that first-class approval workflow exists. Scope: -- Introduce a pending approval result state for blocked mutations that require action rather than hard denial. -- Persist approval requirements to audit and history. -- Add follow-up mutations such as `ApproveRequirement` and `RejectRequirement`. -- Support multi-step approvals such as two-man approval and role-based approval chains. +- Replace generic approval failure paths with explicit domain exceptions and outcomes where they still leak through. +- Add approval groups, role-oriented approver targeting, and richer requirement assignment semantics. +- Support quorum or `N-of-M` approvals rather than only linear step completion. +- Add timeout / expiration policies that are specific to approval requirements, not only request-level pending expiration. +- Introduce a richer rejection reason model for auditability and later query support. Why this matters: -- The policy model already supports `RequireApproval`. -- This turns the engine from allow/deny enforcement into an actual governance workflow engine. +- Approval workflow is no longer hypothetical. +- The next investment is making it operationally expressive rather than merely present. -### 3. Versioned Execution and Concurrency Control +### 3. Version Resolution Semantics Hardening -Add explicit optimistic concurrency handling around mutation execution. +Harden the stale-request and revalidation semantics that now exist in the governance runtime. Scope: -- Introduce version-aware mutation execution based on `stateId` and expected version. -- Use `ConcurrencyException` as a real runtime outcome instead of a dormant abstraction. -- Add optional lock-per-state execution for high-contention scenarios. -- Define how batch execution behaves when a mid-batch concurrency conflict occurs. +- Clarify what `RevalidateOnLatestState` means operationally before execution starts. +- Consider an explicit pending reason or status path for revalidation-driven deferral. +- Add end-to-end scenarios such as: + - approve stale request + - require renewed approval + - approve again + - execute +- Ensure stale handling semantics are visible through request decisions, examples, and tests. Why this matters: -- The library claims deterministic and async-safe behavior. -- Without explicit concurrency semantics, that promise is incomplete for shared state workloads. +- Version resolution exists, but its branch semantics still need to be tightened before governed execution becomes a durable contract. + +### 4. Core Runtime Concurrency + +Close the remaining concurrency gap in the core mutation engine itself. + +Scope: + +- Enforce `MutationEngineOptions.MaxConcurrentMutations` in core execution rather than leaving it as a passive option. +- Introduce explicit concurrency handling around direct state mutation execution. +- Decide whether per-state locking, optimistic execution, or both are part of the core runtime contract. +- Define how direct batch execution behaves when concurrency conflicts appear mid-flight. + +Why this matters: + +- Recent work hardened governance request storage concurrency. +- The underlying core execution runtime still has its own unresolved concurrency contract. ## v1.2 Governance Data Focus: make governed execution queryable, persistent, and classification-aware. -### 1. Persistent History and Audit Stores +### 1. Governance Query Store -Add production-ready adapters beyond in-memory implementations. +Expose operational queries over requests, approvals, and decisions. Scope: -- Entity Framework Core store for audit and history. -- PostgreSQL-oriented store or provider package. -- Optional Redis-backed recent-history cache for hot paths. +- Introduce an `IMutationRequestQueryStore` or equivalent query contract. +- Query pending approvals by actor, group, role, or request category. +- Query requests by `stateId`, request category, tags, governance metadata, and blast radius. +- Query recent request decisions, approval actions, stale resolutions, and execution outcomes. +- Define storage-agnostic query contracts before binding them to a specific provider. Why this matters: -- In-memory implementations are suitable for examples, tests, and development only. -- Production integration is the next natural adoption step. +- Governance data becomes operational once users can ask workflow questions, not just load a single request by id. -### 2. Query API for Audit and History +### 2. Persistent Governance and Audit Providers -Expose richer retrieval primitives than state-id-only history lookup. +Add production-ready persistence adapters beyond in-memory implementations. Scope: -- Query by `actorId`, `category`, `riskLevel`, `sideEffectSeverity`, and time range. -- Query by `tags`, governance metadata, and estimated blast radius. -- Support recent activity queries and filtered timelines. -- Add approval-oriented queries such as pending approvals and recent approval decisions. -- Support risk-oriented filtering and reporting views. -- Define storage-agnostic query contracts first, then implement provider-specific adapters. +- Entity Framework Core provider for governance request storage and query flows. +- PostgreSQL-oriented governance provider package. +- Persistent audit/history provider where governance execution outcomes can be correlated with request data. +- Optional Redis-backed recent-decision cache for hot operational paths if it proves necessary. Why this matters: -- Audit and history become much more useful once users can answer operational questions, not just replay a single state stream. -- Persistence without queryability is only a storage layer, not an operational feature. +- Governance runtime without persistence remains development-friendly but operationally shallow. ### 3. Governance Metadata @@ -145,7 +163,7 @@ Why this matters: Focus: connect governance runtime behaviors to external systems and asynchronous policy sources. -### 1. Async Policies +### 1. Async Policies and External Governance Checks Support policy evaluation that depends on external systems. @@ -154,7 +172,9 @@ Scope: - Introduce `EvaluateAsync(...)` policy support. - Preserve a sync path for lightweight policies. - Define ordering and timeout semantics when multiple async policies are involved. -- Allow approval and governance checks to rely on external identity, ticketing, or compliance systems. +- Allow approval and governance checks to rely on external identity, ticketing, quota, or compliance systems. +- Support ticket-driven approval flows, external quota checks, and identity-provider backed approver resolution. +- Define timeout and cancellation semantics for external governance dependencies. Why this matters: @@ -212,19 +232,20 @@ The longer-term lifecycle model behind these milestones is documented in [`Docs/ ## Recommended Build Order -1. Add pending mutation lifecycle support. -2. Implement approval workflow support for `PolicyRequirement`. -3. Add versioned execution and concurrency handling to the runtime. -4. Add persistent audit/history providers. -5. Add query APIs over persisted governance data. -6. Persist and expose governance metadata. -7. Add typed side effects once persistence and query contracts are stable. -8. Add async policy support, unless external approval integrations force it earlier. -9. Add undo/compensation support once approval and persistence semantics are stable. +1. Add a governed execution manager that resolves and executes approved requests. +2. Harden approval workflow semantics around quorum, roles, and rejection modeling. +3. Tighten version-resolution semantics and revalidation paths. +4. Close remaining core runtime concurrency gaps. +5. Add governance query contracts for requests, approvals, and decisions. +6. Add persistent governance and audit/history providers. +7. Persist and expose governance metadata. +8. Add typed side effects once persistence and query contracts are stable. +9. Add async policy support, unless external approval integrations force it earlier. +10. Add undo/compensation support once governed execution and persistence semantics are stable. ## Not Recommended Yet These ideas may be useful later, but they are not the best next investment: - Distributed execution features before concurrency semantics are explicit. -- More examples before the runtime contract around approvals and concurrency is complete. +- More examples before the governed execution loop is complete. diff --git a/Examples/Governance/ApprovalWorkflow/README.md b/Examples/Governance/ApprovalWorkflow/README.md index d052900..743c6a1 100644 --- a/Examples/Governance/ApprovalWorkflow/README.md +++ b/Examples/Governance/ApprovalWorkflow/README.md @@ -1,6 +1,6 @@ # Governance ApprovalWorkflow -This example shows the governance approval workflow built on top of `MutationRequest.PendingApproval(...)` and `MutationRequestApprovalWorkflowManager`. +This example shows the governance approval workflow built on top of `MutationRequestFactory.PendingApproval(...)` and `MutationRequestApprovalWorkflowManager`. It demonstrates: diff --git a/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs b/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs index d436d61..05d8b52 100644 --- a/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs +++ b/Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs @@ -1,6 +1,7 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Runtime.Approval.Execution; using ModularityKit.Mutator.Governance.Runtime.Storage; @@ -45,7 +46,7 @@ public static async Task Run() private static MutationRequest CreateApprovalRequest() { - return MutationRequest.PendingApproval( + return MutationRequestFactory.PendingApproval( stateId: "tenant-42:roles", stateType: "IamRoleState", mutationType: "GrantRoleMutation", diff --git a/Examples/Governance/GovernedExecution/GovernedExecution.csproj b/Examples/Governance/GovernedExecution/GovernedExecution.csproj new file mode 100644 index 0000000..9b9a0cc --- /dev/null +++ b/Examples/Governance/GovernedExecution/GovernedExecution.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + diff --git a/Examples/Governance/GovernedExecution/Program.cs b/Examples/Governance/GovernedExecution/Program.cs new file mode 100644 index 0000000..396b231 --- /dev/null +++ b/Examples/Governance/GovernedExecution/Program.cs @@ -0,0 +1,3 @@ +using GovernedExecution.Scenarios; + +await GovernanceExecutionScenario.Run(); diff --git a/Examples/Governance/GovernedExecution/README.md b/Examples/Governance/GovernedExecution/README.md new file mode 100644 index 0000000..d0ab66c --- /dev/null +++ b/Examples/Governance/GovernedExecution/README.md @@ -0,0 +1,21 @@ +# GovernedExecution + +This example shows the full governed execution loop: + +- approved request +- version resolution +- mutation execution through the core engine +- terminal `Executed` request decision + +## Key Files + +- [`Program.cs`](Program.cs) +- [`Scenarios/GovernanceExecutionScenario.cs`](Scenarios/GovernanceExecutionScenario.cs) +- [`src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs`](../../../src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs) +- [`src/Governance/Abstractions/Execution/Contracts/IGovernanceExecutionManager.cs`](../../../src/Governance/Abstractions/Execution/Contracts/IGovernanceExecutionManager.cs) + +## Run + +```bash +dotnet run --project Examples/Governance/GovernedExecution/GovernedExecution.csproj +``` diff --git a/Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs b/Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs new file mode 100644 index 0000000..0d30760 --- /dev/null +++ b/Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs @@ -0,0 +1,102 @@ +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.Results; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; +using ModularityKit.Mutator.Governance.Runtime.Execution.Orchestration; +using ModularityKit.Mutator.Governance.Runtime.Resolution.Execution; +using ModularityKit.Mutator.Governance.Runtime.Storage; +using ModularityKit.Mutator.Runtime; + +namespace GovernedExecution.Scenarios; + +internal static class GovernanceExecutionScenario +{ + public static async Task Run() + { + var services = new ServiceCollection(); + services.AddMutators(MutationEngineOptions.Strict); + await using var provider = services.BuildServiceProvider(); + + var engine = provider.GetRequiredService(); + var store = new InMemoryMutationRequestStore(); + var resolutionManager = new MutationRequestVersionResolutionManager(store, new MutationRequestVersionResolver()); + var executionManager = new GovernanceExecutionManager(store, resolutionManager, engine); + + var request = await store.Create(MutationRequestFactory.Approved( + stateId: "tenant-42:feature-flags", + stateType: "FeatureFlagState", + mutationType: nameof(EnableFeatureMutation), + intent: new MutationIntent + { + OperationName = "EnableFeature", + Category = "Configuration", + Description = "Enable a rollout after governance approval" + }, + context: MutationContext.User("requester-1", "Requester One", "Enable guarded rollout"), + expectedStateVersion: "v10")); + + var currentState = new FeatureFlagState( + request.StateId, + IsEnabled: false, + Version: "v10"); + + var execution = await executionManager.ExecuteApproved( + 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); + + Console.WriteLine("=== Governed Execution ==="); + Console.WriteLine($"Executed: {execution.WasExecuted}"); + Console.WriteLine($"Resolution: {execution.Resolution.Outcome}"); + Console.WriteLine($"Request status: {execution.Request.Status}"); + Console.WriteLine($"Resulting version: {execution.ResultingStateVersion ?? "-"}"); + Console.WriteLine($"Last decision: {execution.Request.Decisions[^1].Type}"); + Console.WriteLine($"Reason: {execution.Request.Decisions[^1].Reason}"); + } + + private sealed record FeatureFlagState(string StateId, bool IsEnabled, string Version); + + private sealed class EnableFeatureMutation(MutationContext context, string nextVersion) : IMutation + { + 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) + { + var newState = state with + { + IsEnabled = true, + Version = nextVersion + }; + + return MutationResult.Success( + newState, + ChangeSet.Single(StateChange.Modified("IsEnabled", state.IsEnabled, newState.IsEnabled))); + } + + public 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/RequestLifecycle/README.md b/Examples/Governance/RequestLifecycle/README.md index 722c97f..65665c2 100644 --- a/Examples/Governance/RequestLifecycle/README.md +++ b/Examples/Governance/RequestLifecycle/README.md @@ -6,7 +6,7 @@ It focuses on `MutationRequest`, `IMutationRequestStore`, and `MutationRequestLi ## What it demonstrates -- creating governed requests with `MutationRequest.Pending(...)` and `MutationRequest.Approved(...)` +- creating governed requests with `MutationRequestFactory.Pending(...)` and `MutationRequestFactory.Approved(...)` - storing requests in `InMemoryMutationRequestStore` - moving requests through the lifecycle with `MutationRequestLifecycleManager` - listing pending requests globally and by `StateId` diff --git a/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs b/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs index 469aa89..3ffe98c 100644 --- a/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs +++ b/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs @@ -2,6 +2,7 @@ using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Policies; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Runtime.Lifecycle.Execution; using ModularityKit.Mutator.Governance.Runtime.Storage; @@ -17,7 +18,7 @@ public static async Task Run() var submittedAt = DateTimeOffset.UtcNow; - var quotaApprovalRequest = MutationRequest.Pending( + var quotaApprovalRequest = MutationRequestFactory.Pending( stateId: "tenant-42:quota", stateType: "QuotaPolicy", mutationType: "IncreaseQuotaMutation", @@ -40,7 +41,7 @@ public static async Task Run() ["TenantId"] = "tenant-42" }); - var scheduledRequest = MutationRequest.Pending( + var scheduledRequest = MutationRequestFactory.Pending( stateId: "tenant-42:quota", stateType: "QuotaPolicy", mutationType: "ResetQuotaMutation", @@ -55,7 +56,7 @@ public static async Task Run() expectedStateVersion: "v12", expiresAt: submittedAt.AddMinutes(-5)); - var externalCheckRequest = MutationRequest.Approved( + var externalCheckRequest = MutationRequestFactory.Approved( stateId: "tenant-99:flags", stateType: "FeatureFlagState", mutationType: "EnableFeatureMutation", diff --git a/Examples/Governance/VersionedResolution/README.md b/Examples/Governance/VersionedResolution/README.md index 0b684e1..3619222 100644 --- a/Examples/Governance/VersionedResolution/README.md +++ b/Examples/Governance/VersionedResolution/README.md @@ -36,7 +36,7 @@ var resolver = new MutationRequestVersionResolver(); var manager = new MutationRequestVersionResolutionManager(store, resolver); var request = await store.Create( - MutationRequest.Approved( + MutationRequestFactory.Approved( stateId: "tenant-42:roles", stateType: "IamRoleState", mutationType: "GrantRoleMutation", diff --git a/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs b/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs index 75096b9..8017c94 100644 --- a/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs +++ b/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs @@ -1,5 +1,6 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; @@ -62,7 +63,7 @@ public static async Task Run() private static MutationRequest CreateApprovedRequest(string expectedStateVersion) { - return MutationRequest.Approved( + return MutationRequestFactory.Approved( stateId: "tenant-42:roles", stateType: "IamRoleState", mutationType: "GrantRoleMutation", diff --git a/Examples/README.md b/Examples/README.md index da8142f..9293401 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -23,6 +23,7 @@ The projects are intentionally small and focused. Each one demonstrates a differ | Example | Focus | Readme | | --- | --- | --- | | `RequestLifecycle` | pending requests, lifecycle transitions, expiration, and cancellation | [`Examples/Governance/RequestLifecycle/README.md`](Governance/RequestLifecycle/README.md) | +| `GovernedExecution` | approved request -> resolution -> execution -> executed decision | [`Examples/Governance/GovernedExecution/README.md`](Governance/GovernedExecution/README.md) | | `DecisionTaxonomy` | lifecycle, approval, and version-resolution decision categories | [`Examples/Governance/DecisionTaxonomy/README.md`](Governance/DecisionTaxonomy/README.md) | | `ApprovalWorkflow` | request-level approvals, multi-step sign-off, and governed approval actions | [`Examples/Governance/ApprovalWorkflow/README.md`](Governance/ApprovalWorkflow/README.md) | | `VersionedResolution` | stale request handling and expected state version semantics | [`Examples/Governance/VersionedResolution/README.md`](Governance/VersionedResolution/README.md) | @@ -54,6 +55,7 @@ dotnet build Examples/Core/FeatureFlags/FeatureFlags.csproj -c Release dotnet build Examples/Core/IamRoles/IamRoles.csproj -c Release dotnet build Examples/Core/WorkflowApprovals/WorkflowApprovals.csproj -c Release dotnet build Examples/Governance/RequestLifecycle/RequestLifecycle.csproj -c Release +dotnet build Examples/Governance/GovernedExecution/GovernedExecution.csproj -c Release dotnet build Examples/Governance/DecisionTaxonomy/DecisionTaxonomy.csproj -c Release dotnet build Examples/Governance/ApprovalWorkflow/ApprovalWorkflow.csproj -c Release dotnet build Examples/Governance/VersionedResolution/VersionedResolution.csproj -c Release @@ -71,6 +73,7 @@ dotnet run --project Examples/Core/FeatureFlags/FeatureFlags.csproj dotnet run --project Examples/Core/IamRoles/IamRoles.csproj dotnet run --project Examples/Core/WorkflowApprovals/WorkflowApprovals.csproj dotnet run --project Examples/Governance/RequestLifecycle/RequestLifecycle.csproj +dotnet run --project Examples/Governance/GovernedExecution/GovernedExecution.csproj dotnet run --project Examples/Governance/DecisionTaxonomy/DecisionTaxonomy.csproj dotnet run --project Examples/Governance/ApprovalWorkflow/ApprovalWorkflow.csproj dotnet run --project Examples/Governance/VersionedResolution/VersionedResolution.csproj @@ -137,6 +140,12 @@ Shows the governance runtime as a request lifecycle system instead of an immedia See [`Governance/RequestLifecycle/README.md`](Governance/RequestLifecycle/README.md). +### GovernedExecution + +Shows the full governed execution loop from approved request to version resolution, core mutation execution, and terminal executed decision. + +See [`Governance/GovernedExecution/README.md`](Governance/GovernedExecution/README.md). + ### ApprovalWorkflow Shows how governance turns approval requirements into explicit request-level approval actions with ordered steps and terminal approval or rejection behavior. diff --git a/ModularityKit.Mutator.slnx b/ModularityKit.Mutator.slnx index 27cfc19..f184391 100644 --- a/ModularityKit.Mutator.slnx +++ b/ModularityKit.Mutator.slnx @@ -9,6 +9,7 @@ + diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs index dde1c2c..a3e9453 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Approval/MutationRequestApprovalWorkflowTests.cs @@ -5,6 +5,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Approval; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Runtime.Approval.Execution; using ModularityKit.Mutator.Governance.Runtime.Storage; @@ -17,7 +18,7 @@ public sealed class MutationRequestApprovalWorkflowTests [Fact] public void PendingApproval_maps_policy_requirements_into_visible_request_approval_requirements() { - var request = MutationRequest.PendingApproval( + var request = MutationRequestFactory.PendingApproval( stateId: "tenant-42:roles", stateType: "IamRoleState", mutationType: "GrantRoleMutation", @@ -141,7 +142,7 @@ public async Task RejectRequirement_marks_request_rejected_and_records_explicit_ private static MutationRequest CreateMultiStepApprovalRequest() { - return MutationRequest.PendingApproval( + return MutationRequestFactory.PendingApproval( stateId: "tenant-42:roles", stateType: "IamRoleState", mutationType: "GrantRoleMutation", diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs new file mode 100644 index 0000000..aa3030c --- /dev/null +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs @@ -0,0 +1,184 @@ +using Microsoft.Extensions.DependencyInjection; +using ModularityKit.Mutator.Abstractions; +using ModularityKit.Mutator.Abstractions.Audit; +using ModularityKit.Mutator.Abstractions.Changes; +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.History; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +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; + +namespace ModularityKit.Mutator.Governance.Tests.Execution; + +public sealed class GovernanceExecutionManagerTests +{ + [Fact] + public async Task ExecuteApproved_executes_request_persists_resulting_version_and_correlates_audit_history() + { + var services = new ServiceCollection(); + services.AddMutators(MutationEngineOptions.Strict); + await using var provider = services.BuildServiceProvider(); + + var engine = provider.GetRequiredService(); + var auditor = provider.GetRequiredService(); + var historyStore = provider.GetRequiredService(); + var requestStore = new InMemoryMutationRequestStore(); + var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); + var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); + + var request = await requestStore.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10")); + var mutation = new PromoteRoleMutation( + MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), + nextVersion: "v11"); + var state = RoleState.Create("tenant-42:roles", role: "Reader", version: "v10"); + + var result = await executionManager.ExecuteApproved( + request.RequestId, + mutation, + state, + currentStateVersion: state.Version, + resultingStateVersionProvider: updated => updated.Version, + governanceContext: MutationContext.Service("governance-runtime", "Execute approved request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); + + Assert.True(result.WasExecuted); + Assert.NotNull(result.MutationResult); + Assert.Equal("v11", result.ResultingStateVersion); + Assert.Equal(MutationRequestStatus.Executed, result.Request.Status); + Assert.Equal("v11", result.Request.ResultingStateVersion); + Assert.Equal("v11", result.Request.ExpectedStateVersion); + Assert.NotNull(result.Request.ExecutedAt); + Assert.Equal( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), + result.Request.Decisions[^1].Type); + + var auditEntries = await auditor.GetAuditLogAsync(request.StateId); + var history = await historyStore.GetHistoryAsync(request.StateId); + + Assert.Single(auditEntries); + Assert.Single(history.Entries); + Assert.Equal(request.RequestId, auditEntries[0].Context.Metadata["GovernanceRequestId"]); + Assert.Equal(request.RequestId, history.Entries[0].Context.Metadata["GovernanceRequestId"]); + } + + [Fact] + public async Task ExecuteApproved_does_not_execute_when_stale_resolution_rejects_request() + { + var services = new ServiceCollection(); + services.AddMutators(MutationEngineOptions.Strict); + await using var provider = services.BuildServiceProvider(); + + var engine = provider.GetRequiredService(); + var requestStore = new InMemoryMutationRequestStore(); + var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); + var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); + + var request = await requestStore.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10")); + var mutation = new PromoteRoleMutation( + MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), + nextVersion: "v11"); + var state = RoleState.Create("tenant-42:roles", role: "Reader", version: "v15"); + + var result = await executionManager.ExecuteApproved( + request.RequestId, + mutation, + state, + currentStateVersion: state.Version, + resultingStateVersionProvider: updated => updated.Version, + governanceContext: MutationContext.Service("governance-runtime", "Reject stale request"), + strategy: VersionedRequestResolutionStrategy.RejectStale); + + Assert.False(result.WasExecuted); + Assert.Null(result.MutationResult); + Assert.Equal(MutationRequestStatus.Rejected, result.Request.Status); + Assert.Equal(MutationRequestVersionResolutionOutcome.RejectedAsStale, result.Resolution.Outcome); + Assert.Equal( + MutationRequestDecisionType.VersionResolution(MutationRequestVersionResolutionDecisionType.RejectedAsStale), + result.Request.Decisions[^1].Type); + } + + [Fact] + public async Task ExecuteApproved_requires_renewed_approval_before_execution_when_strategy_demands_it() + { + var services = new ServiceCollection(); + services.AddMutators(MutationEngineOptions.Strict); + await using var provider = services.BuildServiceProvider(); + + var engine = provider.GetRequiredService(); + var requestStore = new InMemoryMutationRequestStore(); + var resolutionManager = new MutationRequestVersionResolutionManager(requestStore, new MutationRequestVersionResolver()); + var executionManager = new GovernanceExecutionManager(requestStore, resolutionManager, engine); + + var request = await requestStore.Create(MutationRequestTestFactory.CreateApprovedSecurityRequest("v10")); + var mutation = new PromoteRoleMutation( + MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), + nextVersion: "v11"); + var state = RoleState.Create("tenant-42:roles", role: "Reader", version: "v15"); + + var result = await executionManager.ExecuteApproved( + request.RequestId, + mutation, + state, + currentStateVersion: state.Version, + resultingStateVersionProvider: updated => updated.Version, + governanceContext: MutationContext.Service("governance-runtime", "Require renewed approval"), + strategy: VersionedRequestResolutionStrategy.RequireRenewedApproval); + + Assert.False(result.WasExecuted); + Assert.Null(result.MutationResult); + Assert.Equal(MutationRequestStatus.Pending, result.Request.Status); + Assert.Equal(PendingMutationReason.Approval, result.Request.PendingReason); + Assert.Equal("v15", result.Request.ExpectedStateVersion); + Assert.Equal(MutationRequestVersionResolutionOutcome.RequiresRenewedApproval, result.Resolution.Outcome); + } + + private sealed record RoleState(string StateId, string Role, string Version) + { + public static RoleState Create(string stateId, string role, string version) => new(stateId, role, version); + } + + private sealed class PromoteRoleMutation(MutationContext context, string nextVersion) : IMutation + { + public MutationIntent Intent { get; } = new() + { + OperationName = "PromoteRole", + Category = "Security", + Description = "Promote tenant role after governance approval" + }; + + public MutationContext Context { get; } = context; + + public MutationResult Apply(RoleState state) + { + var newState = state with + { + Role = "Admin", + Version = nextVersion + }; + + return MutationResult.Success( + newState, + ChangeSet.Single(StateChange.Modified("Role", state.Role, newState.Role))); + } + + public ValidationResult Validate(RoleState state) + { + return state.Role == "Admin" + ? ValidationResult.WithError("Role", "Role is already Admin.") + : ValidationResult.Success(); + } + + public MutationResult Simulate(RoleState state) => Apply(state); + } +} diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj b/Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj index ad54f15..a34a2b9 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj +++ b/Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs index 67849cb..da635c0 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/MutationRequestTestFactory.cs @@ -1,6 +1,7 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Tests.TestSupport; @@ -9,7 +10,7 @@ internal static class MutationRequestTestFactory { public static MutationRequest CreatePendingRequest() { - return MutationRequest.Pending( + return MutationRequestFactory.Pending( stateId: "tenant-42:quota", stateType: "QuotaPolicy", mutationType: "IncreaseQuotaMutation", @@ -26,7 +27,7 @@ public static MutationRequest CreatePendingRequest() public static MutationRequest CreateApprovedSecurityRequest(string expectedStateVersion) { - return MutationRequest.Approved( + return MutationRequestFactory.Approved( stateId: "tenant-42:roles", stateType: "IamRoleState", mutationType: "GrantRoleMutation", diff --git a/src/Governance/Abstractions/Execution/Contracts/IGovernanceExecutionManager.cs b/src/Governance/Abstractions/Execution/Contracts/IGovernanceExecutionManager.cs new file mode 100644 index 0000000..ca79063 --- /dev/null +++ b/src/Governance/Abstractions/Execution/Contracts/IGovernanceExecutionManager.cs @@ -0,0 +1,25 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; + +namespace ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts; + +/// +/// Executes approved governance requests through version resolution and the core mutation engine. +/// +public interface IGovernanceExecutionManager +{ + /// + /// Executes an approved governed mutation request against the provided state snapshot. + /// + Task> ExecuteApproved( + string requestId, + IMutation mutation, + TState currentState, + string currentStateVersion, + Func resultingStateVersionProvider, + MutationContext governanceContext, + VersionedRequestResolutionStrategy strategy, + CancellationToken cancellationToken = default); +} diff --git a/src/Governance/Abstractions/Execution/Model/GovernedExecutionResult.cs b/src/Governance/Abstractions/Execution/Model/GovernedExecutionResult.cs new file mode 100644 index 0000000..7103f77 --- /dev/null +++ b/src/Governance/Abstractions/Execution/Model/GovernedExecutionResult.cs @@ -0,0 +1,36 @@ +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Execution.Model; + +/// +/// Captures the outcome of executing a governed mutation request. +/// +public sealed record GovernedExecutionResult +{ + /// + /// Latest persisted request snapshot after resolution and optional execution. + /// + public MutationRequest Request { get; init; } = null!; + + /// + /// Version-resolution outcome that gated execution. + /// + public MutationRequestVersionResolution Resolution { get; init; } = null!; + + /// + /// Core mutation result when execution actually ran. + /// + public MutationResult? MutationResult { get; init; } + + /// + /// Indicates whether the core mutation engine executed the request. + /// + public bool WasExecuted { get; init; } + + /// + /// Resulting state version recorded after a successful execution. + /// + public string? ResultingStateVersion { get; init; } +} diff --git a/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs b/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs new file mode 100644 index 0000000..81d4f37 --- /dev/null +++ b/src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs @@ -0,0 +1,149 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Governance.Abstractions.Approval.Mapping; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; + +/// +/// Creates governed mutation requests for common governance entry paths. +/// +public static class MutationRequestFactory +{ + /// + /// Creates a request that should enter the pending lifecycle. + /// + public static MutationRequest Pending( + string stateId, + string stateType, + string mutationType, + MutationIntent intent, + MutationContext context, + PendingMutationReason pendingReason, + IReadOnlyList? requirements = null, + string? expectedStateVersion = null, + DateTimeOffset? expiresAt = null, + IReadOnlyDictionary? metadata = null) + { + return new MutationRequest + { + StateId = stateId, + StateType = stateType, + MutationType = mutationType, + Intent = intent, + Context = context, + Status = MutationRequestStatus.Pending, + PendingReason = pendingReason, + Requirements = requirements ?? [], + ExpectedStateVersion = expectedStateVersion, + ExpiresAt = expiresAt, + Metadata = metadata ?? new Dictionary(), + Decisions = + [ + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + context, + reason: context.Reason), + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), + context, + reason: $"Request entered pending lifecycle for reason '{pendingReason}'.") + ] + }; + } + + /// + /// Creates a request that enters pending approval with concrete request-level approval requirements. + /// + public static MutationRequest PendingApproval( + string stateId, + string stateType, + string mutationType, + MutationIntent intent, + MutationContext context, + IReadOnlyList requirements, + string? expectedStateVersion = null, + DateTimeOffset? expiresAt = null, + IReadOnlyDictionary? metadata = null) + { + ArgumentNullException.ThrowIfNull(requirements); + + var approvalRequirements = MutationApprovalRequirementMapper.Map(requirements); + if (approvalRequirements.Count == 0) + throw new InvalidOperationException("Pending approval requests require at least one approval requirement."); + + return new MutationRequest + { + StateId = stateId, + StateType = stateType, + MutationType = mutationType, + Intent = intent, + Context = context, + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval, + Requirements = requirements, + ApprovalRequirements = approvalRequirements, + ExpectedStateVersion = expectedStateVersion, + ExpiresAt = expiresAt, + Metadata = metadata ?? new Dictionary(), + Decisions = + [ + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + context, + reason: context.Reason), + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), + context, + reason: "Request entered pending approval."), + MutationRequestDecision.Create( + MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), + context, + reason: $"Request requires {approvalRequirements.Count} approval action(s).", + metadata: new Dictionary + { + ["ApprovalRequirementCount"] = approvalRequirements.Count + }) + ] + }; + } + + /// + /// Creates a request that is immediately approved for execution. + /// + public static MutationRequest Approved( + string stateId, + string stateType, + string mutationType, + MutationIntent intent, + MutationContext context, + string? expectedStateVersion = null, + IReadOnlyDictionary? metadata = null) + { + return new MutationRequest + { + StateId = stateId, + StateType = stateType, + MutationType = mutationType, + Intent = intent, + Context = context, + Status = MutationRequestStatus.Approved, + ExpectedStateVersion = expectedStateVersion, + Metadata = metadata ?? new Dictionary(), + Decisions = + [ + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), + context, + reason: context.Reason), + MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), + context, + reason: "Approved at submission time") + ] + }; + } +} diff --git a/src/Governance/Abstractions/Requests/Model/MutationRequest.cs b/src/Governance/Abstractions/Requests/Model/MutationRequest.cs index 11f8c96..e3f79f3 100644 --- a/src/Governance/Abstractions/Requests/Model/MutationRequest.cs +++ b/src/Governance/Abstractions/Requests/Model/MutationRequest.cs @@ -1,7 +1,6 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Policies; -using ModularityKit.Mutator.Governance.Abstractions.Approval.Mapping; using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; @@ -78,11 +77,21 @@ public sealed record MutationRequest /// public string? ExpectedStateVersion { get; init; } + /// + /// Resulting version of the target state after successful governed execution. + /// + public string? ResultingStateVersion { get; init; } + /// /// Optional expiration time for pending requests. /// public DateTimeOffset? ExpiresAt { get; init; } + /// + /// Timestamp when governed execution completed successfully. + /// + public DateTimeOffset? ExecutedAt { get; init; } + /// /// Timestamp when the request was first created. /// @@ -97,138 +106,4 @@ public sealed record MutationRequest /// Additional governance metadata carried by the request. /// public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); - - /// - /// Creates a request that should enter the pending lifecycle. - /// - public static MutationRequest Pending( - string stateId, - string stateType, - string mutationType, - MutationIntent intent, - MutationContext context, - PendingMutationReason pendingReason, - IReadOnlyList? requirements = null, - string? expectedStateVersion = null, - DateTimeOffset? expiresAt = null, - IReadOnlyDictionary? metadata = null) - { - return new MutationRequest - { - StateId = stateId, - StateType = stateType, - MutationType = mutationType, - Intent = intent, - Context = context, - Status = MutationRequestStatus.Pending, - PendingReason = pendingReason, - Requirements = requirements ?? [], - ExpectedStateVersion = expectedStateVersion, - ExpiresAt = expiresAt, - Metadata = metadata ?? new Dictionary(), - Decisions = - [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), - context, - reason: context.Reason), - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), - context, - reason: $"Request entered pending lifecycle for reason '{pendingReason}'.") - ] - }; - } - - /// - /// Creates a request that enters pending approval with concrete request-level approval requirements. - /// - public static MutationRequest PendingApproval( - string stateId, - string stateType, - string mutationType, - MutationIntent intent, - MutationContext context, - IReadOnlyList requirements, - string? expectedStateVersion = null, - DateTimeOffset? expiresAt = null, - IReadOnlyDictionary? metadata = null) - { - ArgumentNullException.ThrowIfNull(requirements); - - var approvalRequirements = MutationApprovalRequirementMapper.Map(requirements); - if (approvalRequirements.Count == 0) - throw new InvalidOperationException("Pending approval requests require at least one approval requirement."); - - return new MutationRequest - { - StateId = stateId, - StateType = stateType, - MutationType = mutationType, - Intent = intent, - Context = context, - Status = MutationRequestStatus.Pending, - PendingReason = PendingMutationReason.Approval, - Requirements = requirements, - ApprovalRequirements = approvalRequirements, - ExpectedStateVersion = expectedStateVersion, - ExpiresAt = expiresAt, - Metadata = metadata ?? new Dictionary(), - Decisions = - [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), - context, - reason: context.Reason), - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending), - context, - reason: "Request entered pending approval."), - MutationRequestDecision.Create( - MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested), - context, - reason: $"Request requires {approvalRequirements.Count} approval action(s).", - metadata: new Dictionary - { - ["ApprovalRequirementCount"] = approvalRequirements.Count - }) - ] - }; - } - - /// - /// Creates a request that is immediately approved for execution. - /// - public static MutationRequest Approved( - string stateId, - string stateType, - string mutationType, - MutationIntent intent, - MutationContext context, - string? expectedStateVersion = null, - IReadOnlyDictionary? metadata = null) - { - return new MutationRequest - { - StateId = stateId, - StateType = stateType, - MutationType = mutationType, - Intent = intent, - Context = context, - Status = MutationRequestStatus.Approved, - ExpectedStateVersion = expectedStateVersion, - Metadata = metadata ?? new Dictionary(), - Decisions = - [ - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted), - context, - reason: context.Reason), - MutationRequestDecision.Create( - MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved), - context, - reason: "Approved at submission time") - ] - }; - } -} +} \ No newline at end of file diff --git a/src/Governance/README.md b/src/Governance/README.md index 41498b1..76e43ef 100644 --- a/src/Governance/README.md +++ b/src/Governance/README.md @@ -10,106 +10,158 @@ The core package stays responsible for direct mutation execution. Governance bui - **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 -## Current Structure +## Governance Flow -### Abstractions +The package is built around a request-driven governance loop: -The package defines governance-first abstractions under: +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 -- `Abstractions/Requests` -- `Abstractions/Approval` -- `Abstractions/Lifecycle` -- `Abstractions/Storage` -- `Abstractions/Resolution` -- `Abstractions/Exceptions` +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. -Key types: +### Request Model - `MutationRequest` -- `MutationApprovalRequirement` -- `MutationApprovalRequirementStatus` +- `MutationRequestFactory` - `MutationRequestDecision` -- `MutationRequestDecisionType` -- `MutationRequestDecisionCategory` -- `MutationRequestLifecycleDecisionType` -- `MutationRequestApprovalDecisionType` -- `MutationRequestVersionResolutionDecisionType` - `MutationRequestStatus` - `PendingMutationReason` + +Use these to create and inspect governed requests. + +### Storage + - `IMutationRequestStore` -- `IMutationRequestApprovalWorkflowManager` +- `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 + - `IMutationRequestVersionResolver` - `IMutationRequestVersionResolutionManager` - `MutationRequestVersionResolution` - `MutationRequestVersionResolutionOutcome` - `VersionedRequestResolutionStrategy` -- `MutationRequestAlreadyExistsException` -- `MutationApprovalRequirementNotFoundException` -- `InvalidMutationApprovalActionException` -- `MutationRequestConcurrencyException` -- `MutationRequestNotFoundException` -- `InvalidMutationRequestTransitionException` -`Abstractions/Requests` is further split into: +Use resolution services to decide what happens when deferred request no longer matches the state version it was created against. + +### Governed Execution + +- `IGovernanceExecutionManager` +- `GovernanceExecutionManager` +- `GovernedExecutionResult` + +Use governed execution to close the loop from approved request to core mutation execution and terminal governance state. + +## Package Areas + +The codebase is organized by governance concern rather than by framework layer alone. + +### Requests + +`Abstractions/Requests` contains the durable request model, decision taxonomy, and request factory methods. - `Requests/Model` - `Requests/Decisions` +- `Requests/Factory` + +### Lifecycle + +`Lifecycle` owns generic request movement between governance states such as pending, approved, rejected, expired, superseded, and executed. -`Abstractions/Approval` is further split into: +- `Lifecycle/Contracts` +- `Lifecycle/Model` +- `Runtime/Lifecycle/Execution` +- `Runtime/Lifecycle/Validation` +- `Runtime/Lifecycle/State` + +### Approval + +`Approval` builds request-level approval workflow on top of the generic lifecycle model. - `Approval/Contracts` - `Approval/Model` - `Approval/Mapping` +- `Runtime/Approval/Execution` +- `Runtime/Approval/State` + +### Resolution -`Abstractions/Resolution` is further split into: +`Resolution` owns version-aware request handling before governed execution. - `Resolution/Contracts` - `Resolution/Model` - `Resolution/Strategies` +- `Runtime/Resolution/Evaluation` +- `Runtime/Resolution/Execution` -`Abstractions/Lifecycle` is further split into: +### Execution -- `Lifecycle/Contracts` -- `Lifecycle/Model` +`Execution` owns the bridge from governance request semantics into the core mutation engine. -`Abstractions/Exceptions` is further split into: +- `Execution/Contracts` +- `Execution/Model` +- `Runtime/Execution/Mutation` +- `Runtime/Execution/Orchestration` +- `Runtime/Execution/Outcome` +- `Runtime/Execution/Persistence` -- `Exceptions/Approval` -- `Exceptions/Lifecycle` -- `Exceptions/Storage` +### Storage and Exceptions -### Runtime +`Storage` defines persistence seams. `Exceptions` contains governance-specific failures grouped by concern. -The initial runtime layer currently provides: - -- `Runtime/Storage/InMemoryMutationRequestStore` -- `Runtime/Approval/MutationRequestApprovalWorkflowManager` -- `Runtime/Lifecycle/MutationRequestLifecycleManager` -- `Runtime/Resolution/MutationRequestVersionResolver` -- `Runtime/Resolution/MutationRequestVersionResolutionManager` - -`Runtime/Resolution` is further split into: - -- `Resolution/Evaluation` -- `Resolution/Execution` +- `Abstractions/Storage` +- `Abstractions/Exceptions/Approval` +- `Abstractions/Exceptions/Lifecycle` +- `Abstractions/Exceptions/Storage` -`Runtime/Lifecycle` is further split into: +## What Exists Today -- `Lifecycle/Execution` -- `Lifecycle/Validation` -- `Lifecycle/State` +Today the package already provides: -`Runtime/Approval` is further split into: +- durable `MutationRequest` modeling +- request-level approval requirements +- 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 -- `Approval/Execution` -- `Approval/State` +What it does not try to do yet: -This keeps the first version small while leaving room for later persistence providers such as Entity Framework Core or PostgreSQL-backed governance stores. +- 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 @@ -135,14 +187,13 @@ Responsible for: ## Direction -This package is intentionally the place where broader governance behavior should grow. +This package is the place where broader governance behavior should grow without turning the core mutation engine into a workflow framework. -That includes future work such as: +The near-term direction is: -- pending mutation resolution -- approval workflow execution -- version aware deferred execution -- governance persistence providers -- governance query APIs +- harden governed execution semantics +- add governance persistence and query providers +- expose governance metadata operationally +- support richer approval and integration scenarios The goal is to keep the core runtime small and execution focused while letting governance evolve as an opt-in extension. diff --git a/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs b/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs new file mode 100644 index 0000000..437a436 --- /dev/null +++ b/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs @@ -0,0 +1,61 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Results; + +namespace ModularityKit.Mutator.Governance.Runtime.Execution.Mutation; + +/// +/// Wraps a mutation so governance request identifiers flow into core execution audit and history metadata. +/// +internal sealed class GovernedMutation : IMutation +{ + private readonly IMutation _inner; + + public GovernedMutation(IMutation inner, string requestId, string stateId) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + + Intent = new MutationIntent + { + OperationName = _inner.Intent.OperationName, + Category = _inner.Intent.Category, + Description = _inner.Intent.Description, + RiskLevel = _inner.Intent.RiskLevel, + IsReversible = _inner.Intent.IsReversible, + EstimatedBlastRadius = _inner.Intent.EstimatedBlastRadius, + Tags = _inner.Intent.Tags, + CreatedAt = _inner.Intent.CreatedAt, + Metadata = MergeMetadata(_inner.Intent.Metadata, requestId) + }; + + Context = _inner.Context with + { + StateId = string.IsNullOrWhiteSpace(_inner.Context.StateId) ? stateId : _inner.Context.StateId, + CorrelationId = string.IsNullOrWhiteSpace(_inner.Context.CorrelationId) ? requestId : _inner.Context.CorrelationId, + Metadata = MergeMetadata(_inner.Context.Metadata, requestId) + }; + } + + public MutationIntent Intent { get; } + + public MutationContext Context { get; } + + public MutationResult Apply(TState state) => _inner.Apply(state); + + public ValidationResult Validate(TState state) => _inner.Validate(state); + + public MutationResult Simulate(TState state) => _inner.Simulate(state); + + private static IReadOnlyDictionary MergeMetadata( + IReadOnlyDictionary source, + string requestId) + { + var metadata = new Dictionary(source) + { + ["GovernanceRequestId"] = requestId + }; + + return metadata; + } +} diff --git a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs new file mode 100644 index 0000000..c2da7a5 --- /dev/null +++ b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs @@ -0,0 +1,123 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Contracts; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Contracts; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Strategies; +using ModularityKit.Mutator.Governance.Abstractions.Storage; +using ModularityKit.Mutator.Governance.Runtime.Execution.Mutation; +using ModularityKit.Mutator.Governance.Runtime.Execution.Outcome; +using ModularityKit.Mutator.Governance.Runtime.Execution.Persistence; + +namespace ModularityKit.Mutator.Governance.Runtime.Execution.Orchestration; + +/// +/// Closes the loop from approved governance request to core mutation execution and terminal request state. +/// +public sealed class GovernanceExecutionManager( + IMutationRequestStore requestStore, + IMutationRequestVersionResolutionManager resolutionManager, + IMutationEngine mutationEngine) : IGovernanceExecutionManager +{ + private readonly IMutationRequestVersionResolutionManager _resolutionManager = resolutionManager ?? throw new ArgumentNullException(nameof(resolutionManager)); + private readonly IMutationEngine _mutationEngine = mutationEngine ?? throw new ArgumentNullException(nameof(mutationEngine)); + private readonly GovernedExecutionOutcomeHandler _outcomeHandler = + new(new GovernedExecutionRequestPersistence(requestStore ?? throw new ArgumentNullException(nameof(requestStore)))); + + public async Task> ExecuteApproved( + string requestId, + IMutation mutation, + TState currentState, + string currentStateVersion, + Func resultingStateVersionProvider, + MutationContext governanceContext, + VersionedRequestResolutionStrategy strategy, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(requestId)) + throw new ArgumentException("Request ID is required.", nameof(requestId)); + + if (string.IsNullOrWhiteSpace(currentStateVersion)) + throw new ArgumentException("Current state version is required.", nameof(currentStateVersion)); + + ArgumentNullException.ThrowIfNull(mutation); + ArgumentNullException.ThrowIfNull(resultingStateVersionProvider); + ArgumentNullException.ThrowIfNull(governanceContext); + + var resolution = await _resolutionManager.ResolveAndStore( + requestId, + currentStateVersion, + governanceContext, + strategy, + cancellationToken).ConfigureAwait(false); + + if (resolution.Outcome is MutationRequestVersionResolutionOutcome.RejectedAsStale or + MutationRequestVersionResolutionOutcome.RequiresRenewedApproval) + { + return _outcomeHandler.BuildNonExecutedResult(resolution); + } + + var governedMutation = new GovernedMutation(mutation, requestId, resolution.Request.StateId); + MutationResult mutationResult; + + try + { + mutationResult = await _mutationEngine + .ExecuteAsync(governedMutation, currentState, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + await _outcomeHandler.PersistRejectedExecution( + resolution.Request, + governanceContext, + $"Governed execution threw '{ex.GetType().Name}': {ex.Message}", + new Dictionary + { + ["CurrentStateVersion"] = currentStateVersion, + ["ExecutionFailureType"] = ex.GetType().Name + }, + cancellationToken).ConfigureAwait(false); + + throw; + } + + if (!mutationResult.IsSuccess || mutationResult.NewState is null) + { + var rejectedRequest = await _outcomeHandler.PersistRejectedExecution( + resolution.Request, + governanceContext, + GovernedExecutionDecisionFactory.BuildRejectedExecutionReason(mutationResult), + new Dictionary + { + ["CurrentStateVersion"] = currentStateVersion, + ["HasPolicyDecisions"] = mutationResult.PolicyDecisions.Count > 0, + ["HasValidationErrors"] = !mutationResult.ValidationResult.IsValid + }, + cancellationToken).ConfigureAwait(false); + + return _outcomeHandler.BuildNonExecutedResult( + resolution with { Request = rejectedRequest }, + mutationResult); + } + + var resultingStateVersion = resultingStateVersionProvider(mutationResult.NewState); + if (string.IsNullOrWhiteSpace(resultingStateVersion)) + throw new InvalidOperationException("Governed execution requires a non-empty resulting state version."); + + var executedRequest = await _outcomeHandler.PersistExecutedRequest( + resolution.Request, + resultingStateVersion, + governanceContext, + mutationResult, + cancellationToken).ConfigureAwait(false); + + return _outcomeHandler.BuildExecutedResult( + resolution, + mutationResult, + executedRequest, + resultingStateVersion); + } +} diff --git a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs new file mode 100644 index 0000000..66b1eca --- /dev/null +++ b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionDecisionFactory.cs @@ -0,0 +1,51 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; + +namespace ModularityKit.Mutator.Governance.Runtime.Execution.Outcome; + +/// +/// Creates governance execution decisions and reason text for terminal request transitions. +/// +internal static class GovernedExecutionDecisionFactory +{ + public static MutationRequestDecision CreateRejectedDecision( + MutationContext governanceContext, + string reason, + IReadOnlyDictionary metadata) + { + return MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Rejected), + governanceContext, + reason, + metadata); + } + + public static MutationRequestDecision CreateExecutedDecision( + MutationContext governanceContext, + string resultingStateVersion, + MutationResult mutationResult) + { + return MutationRequestDecision.Create( + MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed), + governanceContext, + "Governed request executed successfully.", + new Dictionary + { + ["ResultingStateVersion"] = resultingStateVersion, + ["ChangeCount"] = mutationResult.Changes.Count, + ["SideEffectCount"] = mutationResult.SideEffects.Count + }); + } + + public static string BuildRejectedExecutionReason(MutationResult mutationResult) + { + if (mutationResult.PolicyDecisions.Count > 0) + return mutationResult.PolicyDecisions[0].Reason ?? "Governed execution was blocked by policy."; + + if (!mutationResult.ValidationResult.IsValid && mutationResult.ValidationResult.Errors.Count > 0) + return mutationResult.ValidationResult.Errors[0].Message; + + return "Governed execution completed without a successful mutation result."; + } +} diff --git a/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs new file mode 100644 index 0000000..74e7498 --- /dev/null +++ b/src/Governance/Runtime/Execution/Outcome/GovernedExecutionOutcomeHandler.cs @@ -0,0 +1,95 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Execution.Model; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; +using ModularityKit.Mutator.Governance.Runtime.Execution.Persistence; + +namespace ModularityKit.Mutator.Governance.Runtime.Execution.Outcome; + +/// +/// Maps governed execution success and failure into terminal request state transitions. +/// +internal sealed class GovernedExecutionOutcomeHandler(GovernedExecutionRequestPersistence persistence) +{ + private readonly GovernedExecutionRequestPersistence _persistence = persistence ?? throw new ArgumentNullException(nameof(persistence)); + + public async Task PersistRejectedExecution( + MutationRequest request, + MutationContext governanceContext, + string reason, + IReadOnlyDictionary metadata, + CancellationToken cancellationToken) + { + var decision = GovernedExecutionDecisionFactory.CreateRejectedDecision( + governanceContext, + reason, + metadata); + + var rejectedRequest = request with + { + Status = MutationRequestStatus.Rejected, + PendingReason = null, + UpdatedAt = decision.Timestamp, + Decisions = [.. request.Decisions, decision] + }; + + return await _persistence.Persist(request, rejectedRequest, cancellationToken).ConfigureAwait(false); + } + + public async Task PersistExecutedRequest( + MutationRequest request, + string resultingStateVersion, + MutationContext governanceContext, + MutationResult mutationResult, + CancellationToken cancellationToken) + { + var decision = GovernedExecutionDecisionFactory.CreateExecutedDecision( + governanceContext, + resultingStateVersion, + mutationResult); + + var executedRequest = request with + { + Status = MutationRequestStatus.Executed, + PendingReason = null, + ExpectedStateVersion = resultingStateVersion, + ResultingStateVersion = resultingStateVersion, + ExecutedAt = decision.Timestamp, + UpdatedAt = decision.Timestamp, + Decisions = [.. request.Decisions, decision] + }; + + return await _persistence.Persist(request, executedRequest, cancellationToken).ConfigureAwait(false); + } + + public GovernedExecutionResult BuildNonExecutedResult( + MutationRequestVersionResolution resolution, + MutationResult? mutationResult = null) + { + return new GovernedExecutionResult + { + Request = resolution.Request, + Resolution = resolution, + MutationResult = mutationResult, + WasExecuted = false + }; + } + + public GovernedExecutionResult BuildExecutedResult( + MutationRequestVersionResolution resolution, + MutationResult mutationResult, + MutationRequest executedRequest, + string resultingStateVersion) + { + return new GovernedExecutionResult + { + Request = executedRequest, + Resolution = resolution with { Request = executedRequest }, + MutationResult = mutationResult, + WasExecuted = true, + ResultingStateVersion = resultingStateVersion + }; + } +} diff --git a/src/Governance/Runtime/Execution/Persistence/GovernedExecutionRequestPersistence.cs b/src/Governance/Runtime/Execution/Persistence/GovernedExecutionRequestPersistence.cs new file mode 100644 index 0000000..8132def --- /dev/null +++ b/src/Governance/Runtime/Execution/Persistence/GovernedExecutionRequestPersistence.cs @@ -0,0 +1,28 @@ +using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Abstractions.Storage; + +namespace ModularityKit.Mutator.Governance.Runtime.Execution.Persistence; + +/// +/// Persists request transitions for governed execution with optimistic concurrency checks. +/// +internal sealed class GovernedExecutionRequestPersistence(IMutationRequestStore requestStore) +{ + private readonly IMutationRequestStore _requestStore = requestStore ?? throw new ArgumentNullException(nameof(requestStore)); + + public async Task Persist( + MutationRequest previousRequest, + MutationRequest nextRequest, + CancellationToken cancellationToken) + { + var persistedRequest = await _requestStore + .TryStore(nextRequest, previousRequest.Revision, cancellationToken) + .ConfigureAwait(false); + + if (persistedRequest is null) + throw new MutationRequestConcurrencyException(previousRequest.RequestId, previousRequest.Revision); + + return persistedRequest; + } +}