diff --git a/.github/workflows/publish-artifacts.yml b/.github/workflows/publish-artifacts.yml index a8180be..e065820 100644 --- a/.github/workflows/publish-artifacts.yml +++ b/.github/workflows/publish-artifacts.yml @@ -13,7 +13,7 @@ permissions: jobs: package: - name: Pack library + name: Pack packages runs-on: ubuntu-latest steps: @@ -26,7 +26,7 @@ jobs: dotnet-version: 10.0.x - name: Restore - run: dotnet restore src/ModularityKit.Mutator.csproj + run: dotnet restore ModularityKit.Mutator.slnx - name: Resolve package version id: version @@ -44,7 +44,7 @@ jobs: fi echo "package_version=$version" >> "$GITHUB_OUTPUT" - - name: Pack package + - name: Pack core package run: > dotnet pack src/ModularityKit.Mutator.csproj -c Release @@ -52,8 +52,16 @@ jobs: -o nupkg -p:PackageVersion=${{ steps.version.outputs.package_version }} - - name: Upload package + - name: Pack governance package + run: > + dotnet pack src/ModularityKit.Mutator.Governance.csproj + -c Release + --no-restore + -o nupkg + -p:PackageVersion=${{ steps.version.outputs.package_version }} + + - name: Upload packages uses: actions/upload-artifact@v4 with: - name: ModularityKit.Mutator-nupkg + name: ModularityKit-packages path: nupkg/*.nupkg diff --git a/.github/workflows/publish-attested.yml b/.github/workflows/publish-attested.yml index e5171aa..f3324ef 100644 --- a/.github/workflows/publish-attested.yml +++ b/.github/workflows/publish-attested.yml @@ -25,7 +25,7 @@ jobs: - name: Download published artifacts uses: actions/download-artifact@v6 with: - pattern: ModularityKit.Mutator-nupkg + pattern: ModularityKit-packages path: dist merge-multiple: true diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 39487da..fecc991 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -52,7 +52,7 @@ jobs: - name: Download published artifacts uses: actions/download-artifact@v6 with: - pattern: ModularityKit.Mutator-nupkg + pattern: ModularityKit-packages path: dist merge-multiple: true diff --git a/Docs/Decision/Adr/ADR_023_Governance_Versioned_Request_Resolution.md b/Docs/Decision/Adr/ADR_023_Governance_Versioned_Request_Resolution.md index 46d6565..ee6c31a 100644 --- a/Docs/Decision/Adr/ADR_023_Governance_Versioned_Request_Resolution.md +++ b/Docs/Decision/Adr/ADR_023_Governance_Versioned_Request_Resolution.md @@ -4,7 +4,7 @@ #adr_023 ## Status -Proposed +Accepted ## Date 2026-06-21 @@ -33,19 +33,33 @@ This is a governance concern, not just a core mutation concern, because it only ## Decision -The governance package should adopt explicit version-aware request resolution semantics. - -Expected direction: +The governance package adopts explicit version-aware request resolution semantics. - `MutationRequest` keeps an `ExpectedStateVersion` -- request resolution must compare current state version with expected version -- stale requests must not silently execute without an explicit rule -- runtime resolution should choose among: - - re-validate and execute against latest state - - reject as stale - - require renewed approval - -The exact resolution policy is intentionally left open for the first runtime implementation. +- request resolution compares current state version with expected version +- stale requests do not silently execute +- governance runtime resolves stale requests through one of three explicit strategies: + - `RejectStale` + - `RequireRenewedApproval` + - `RevalidateOnLatestState` + +Current runtime contract: + +- matching version, or no expected version: + - request receives `VersionValidated` + - outcome is `ExecuteApprovedVersion` +- stale request with `RejectStale`: + - request becomes `Rejected` + - request receives `RejectedAsStale` +- stale request with `RequireRenewedApproval`: + - request returns to `Pending` + - `PendingReason` becomes `Approval` + - `ExpectedStateVersion` is updated to the current version + - request receives `RenewedApprovalRequired` +- stale request with `RevalidateOnLatestState`: + - request stays `Approved` + - `ExpectedStateVersion` is updated to the current version + - request receives `RevalidationRequired` ## Design Rationale @@ -57,15 +71,18 @@ The exact resolution policy is intentionally left open for the first runtime imp ### Positive -- Governance runtime will have explicit semantics for stale approvals. +- Governance runtime now has explicit semantics for stale approvals. - Deferred execution becomes safer and more auditable. +- Request decision history reflects stale detection and final resolution path. ### Negative - This introduces additional policy and runtime complexity. - Different domains may want different stale resolution strategies. +- Revalidation itself is still a separate runtime step beyond this version-resolution contract. ## Related ADRs - ADR-020: Governance MutationRequest Model - ADR-021: Governance Pending Mutation Lifecycle +- ADR-022: Governance Request Decisions and Storage diff --git a/Docs/Decision/Adr/ADR_024_Governance_Runtime_Pending_Request_Handling.md b/Docs/Decision/Adr/ADR_024_Governance_Runtime_Pending_Request_Handling.md new file mode 100644 index 0000000..d51d603 --- /dev/null +++ b/Docs/Decision/Adr/ADR_024_Governance_Runtime_Pending_Request_Handling.md @@ -0,0 +1,78 @@ +# ADR-024: Governance Runtime Pending Request Handling + +## Tag +#adr_024 + +## Status +Accepted + +## Date +2026-06-22 + +## Scope +ModularityKit.Mutator.Governance.Runtime + +## Context + +The governance package already defines: + +- `MutationRequest` +- `MutationRequestStatus` +- `PendingMutationReason` +- `MutationRequestDecision` +- `IMutationRequestStore` + +That gives the package a request model, but not yet a runtime lifecycle. + +To become operational, governance must handle requests that stay pending over time instead of treating them as static records. This includes: + +- entering pending state +- canceling pending requests +- expiring pending requests +- superseding older requests +- listing and resolving pending requests + +Without this runtime layer, governance remains only a set of abstractions and cannot drive actual request lifecycle flows. + +## Decision + +The governance package should introduce first-class runtime handling for pending mutation requests. + +Expected direction: + +- a governance runtime service should own pending request transitions +- pending transitions must be recorded through `MutationRequestDecision` +- expiration and cancellation must be explicit runtime actions +- pending requests must remain queryable by status and pending reason +- the runtime must not collapse pending state into ordinary mutation failure semantics + +The first implementation uses: + +- `IMutationRequestLifecycleManager` as the runtime transition contract +- `MutationRequestLifecycleManager` as the default runtime implementation +- explicit `MutationRequestDecision` entries for pending, approval, rejection, cancellation, expiration, superseding, and execution transitions +- `IMutationRequestStore.GetPendingByStateIdAsync(...)` for listing pending requests by state and reason + +## Design Rationale + +- The model already distinguishes request lifecycle from direct execution. +- Pending execution is central to governance and should be handled explicitly. +- Runtime transitions should remain inside the governance package instead of leaking into the core mutation engine. + +## Consequences + +### Positive + +- Governance can evolve from static request records into a real deferred execution runtime. +- Future approval, scheduling, and external check flows can share one pending lifecycle engine. + +### Negative + +- This introduces a new runtime layer with state transition rules that need consistent enforcement. +- The first implementation must define carefully which transitions are allowed from each status. + +## Related ADRs + +- ADR-020: Governance MutationRequest Model +- ADR-021: Governance Pending Mutation Lifecycle +- ADR-022: Governance Request Decisions and Storage diff --git a/Docs/Decision/Adr/ADR_025_Governance_Approval_Workflow.md b/Docs/Decision/Adr/ADR_025_Governance_Approval_Workflow.md new file mode 100644 index 0000000..894a19b --- /dev/null +++ b/Docs/Decision/Adr/ADR_025_Governance_Approval_Workflow.md @@ -0,0 +1,67 @@ +# ADR-025: Governance Approval Workflow + +## Tag +#adr_025 + +## Status +Proposed + +## Date +2026-06-22 + +## Scope +ModularityKit.Mutator.Governance + +## Context + +The core mutation model already supports `PolicyRequirement`, and the governance package already introduces pending mutation requests. + +What is still missing is the explicit approval flow that connects both concepts: + +- a mutation request enters `PendingApproval` +- request-level requirements become visible and durable +- approvers can approve or reject +- decisions become part of request history +- approved requests move toward execution + +Without this workflow, approval remains only a modeled intention and not an executable governance capability. + +## Decision + +The governance package should implement approval as a first-class specialization of pending request lifecycle. + +Expected direction: + +- `PolicyRequirement` should map into request-level approval requirements +- approval should not be represented only as a policy denial outcome +- approval and rejection must be explicit governance actions +- approval decisions must be recorded through `MutationRequestDecision` +- approved requests must have a defined path toward execution +- rejected requests must transition into a terminal governed status + +Multi-step and multi-actor approvals should be supported by the model, even if the first runtime implementation starts with a simpler flow. + +## Design Rationale + +- Approval is one of the main reasons to introduce governance separately from core runtime. +- The request model already provides the right seam for deferred approval-based execution. +- Approval should build on pending lifecycle rather than creating a parallel flow model. + +## Consequences + +### Positive + +- Governance gains a real approval process instead of a placeholder concept. +- Request history becomes meaningful for approval-driven changes. +- Future integrations with identity, ticketing, or compliance systems have a natural workflow hook. + +### Negative + +- Approval introduces additional lifecycle complexity and version-drift concerns. +- The runtime must define how approved requests behave when state has changed since submission. + +## Related ADRs + +- ADR-021: Governance Pending Mutation Lifecycle +- ADR-023: Governance Versioned Request Resolution +- ADR-024: Governance Runtime Pending Request Handling diff --git a/Docs/Decision/Adr/ADR_026_Governance_Request_Query_API.md b/Docs/Decision/Adr/ADR_026_Governance_Request_Query_API.md new file mode 100644 index 0000000..80105fb --- /dev/null +++ b/Docs/Decision/Adr/ADR_026_Governance_Request_Query_API.md @@ -0,0 +1,67 @@ +# ADR-026: Governance Request Query API + +## Tag +#adr_026 + +## Status +Proposed + +## Date +2026-06-22 + +## Scope +ModularityKit.Mutator.Governance + +## Context + +The governance package is moving toward durable request lifecycle, approval history, and future persistence providers. + +Point lookups by request id are not enough for operational governance scenarios. Users need queries such as: + +- all pending approvals +- requests for a given state +- requests by actor +- requests by category or risk level +- requests filtered by tags, metadata, or blast radius +- recent approval decisions + +Without a query API, persistence becomes only storage and governance data remains hard to use operationally. + +## Decision + +The governance package should define a storage-agnostic request query API. + +Expected direction: + +- expose query-oriented contracts in the governance package +- support filtering by status, pending reason, actor, category, and time range +- support governance-specific filters such as tags, metadata, and blast radius +- support approval-oriented views such as pending approval queues and recent decisions +- keep the query surface provider-neutral so future persistence packages can implement it consistently + +The exact query object model is intentionally left open for the first implementation. + +## Design Rationale + +- Governance data becomes useful only when it is operationally queryable. +- Query semantics should be owned by governance rather than improvised in store implementations. +- A storage-agnostic contract keeps future provider packages aligned. + +## Consequences + +### Positive + +- Governance can support real review and operational workflows. +- Future persistence providers have a clear contract to implement. +- Tags, metadata, and blast radius fields get a practical consumer path. + +### Negative + +- Query surface design can grow quickly if not kept disciplined. +- Different storage providers may support different performance characteristics for the same filters. + +## Related ADRs + +- ADR-020: Governance MutationRequest Model +- ADR-022: Governance Request Decisions and Storage +- ADR-023: Governance Versioned Request Resolution diff --git a/Docs/Decision/listadr.md b/Docs/Decision/listadr.md index 31564ae..8c04d7b 100644 --- a/Docs/Decision/listadr.md +++ b/Docs/Decision/listadr.md @@ -39,5 +39,8 @@ These ADRs describe the `ModularityKit.Mutator.Governance` extension layer and i | ADR-021 | Governance Pending Mutation Lifecycle | [ADR-021](Adr/ADR_021_Governance_Pending_Mutation_Lifecycle.md) | | ADR-022 | Governance Request Decisions and Storage | [ADR-022](Adr/ADR_022_Governance_Request_Decisions_and_Storage.md) | | ADR-023 | Governance Versioned Request Resolution | [ADR-023](Adr/ADR_023_Governance_Versioned_Request_Resolution.md) | +| 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) | > See individual ADRs for detailed context, decision rationale, and consequences. diff --git a/Examples/BillingQuotas/BillingQuotas.csproj b/Examples/Core/BillingQuotas/BillingQuotas.csproj similarity index 84% rename from Examples/BillingQuotas/BillingQuotas.csproj rename to Examples/Core/BillingQuotas/BillingQuotas.csproj index 467e152..a6ca298 100644 --- a/Examples/BillingQuotas/BillingQuotas.csproj +++ b/Examples/Core/BillingQuotas/BillingQuotas.csproj @@ -8,7 +8,7 @@ - + diff --git a/Examples/BillingQuotas/Mutations/DecreaseQuotaMutation.cs b/Examples/Core/BillingQuotas/Mutations/DecreaseQuotaMutation.cs similarity index 100% rename from Examples/BillingQuotas/Mutations/DecreaseQuotaMutation.cs rename to Examples/Core/BillingQuotas/Mutations/DecreaseQuotaMutation.cs diff --git a/Examples/BillingQuotas/Mutations/IncreaseQuotaMutation.cs b/Examples/Core/BillingQuotas/Mutations/IncreaseQuotaMutation.cs similarity index 100% rename from Examples/BillingQuotas/Mutations/IncreaseQuotaMutation.cs rename to Examples/Core/BillingQuotas/Mutations/IncreaseQuotaMutation.cs diff --git a/Examples/BillingQuotas/Mutations/ResetQuotaMutation.cs b/Examples/Core/BillingQuotas/Mutations/ResetQuotaMutation.cs similarity index 100% rename from Examples/BillingQuotas/Mutations/ResetQuotaMutation.cs rename to Examples/Core/BillingQuotas/Mutations/ResetQuotaMutation.cs diff --git a/Examples/BillingQuotas/Policies/MaxQuotaPolicy.cs b/Examples/Core/BillingQuotas/Policies/MaxQuotaPolicy.cs similarity index 100% rename from Examples/BillingQuotas/Policies/MaxQuotaPolicy.cs rename to Examples/Core/BillingQuotas/Policies/MaxQuotaPolicy.cs diff --git a/Examples/BillingQuotas/Policies/PreventNegativeQuotaPolicy.cs b/Examples/Core/BillingQuotas/Policies/PreventNegativeQuotaPolicy.cs similarity index 100% rename from Examples/BillingQuotas/Policies/PreventNegativeQuotaPolicy.cs rename to Examples/Core/BillingQuotas/Policies/PreventNegativeQuotaPolicy.cs diff --git a/Examples/BillingQuotas/Program.cs b/Examples/Core/BillingQuotas/Program.cs similarity index 100% rename from Examples/BillingQuotas/Program.cs rename to Examples/Core/BillingQuotas/Program.cs diff --git a/Examples/BillingQuotas/README.md b/Examples/Core/BillingQuotas/README.md similarity index 98% rename from Examples/BillingQuotas/README.md rename to Examples/Core/BillingQuotas/README.md index d60a053..a26b4ad 100644 --- a/Examples/BillingQuotas/README.md +++ b/Examples/Core/BillingQuotas/README.md @@ -138,7 +138,7 @@ It shows: ## Run ```bash -dotnet run --project Examples/BillingQuotas/BillingQuotas.csproj +dotnet run --project Examples/Core/BillingQuotas/BillingQuotas.csproj ``` ## Expected output @@ -152,4 +152,3 @@ The sample prints: - aggregate engine statistics The exact numbers depend on the runtime and any policy thresholds you change. - diff --git a/Examples/BillingQuotas/Scenarios/EmergencyIncreaseScenario.cs b/Examples/Core/BillingQuotas/Scenarios/EmergencyIncreaseScenario.cs similarity index 100% rename from Examples/BillingQuotas/Scenarios/EmergencyIncreaseScenario.cs rename to Examples/Core/BillingQuotas/Scenarios/EmergencyIncreaseScenario.cs diff --git a/Examples/BillingQuotas/Scenarios/MonthlyResetScenario.cs b/Examples/Core/BillingQuotas/Scenarios/MonthlyResetScenario.cs similarity index 100% rename from Examples/BillingQuotas/Scenarios/MonthlyResetScenario.cs rename to Examples/Core/BillingQuotas/Scenarios/MonthlyResetScenario.cs diff --git a/Examples/BillingQuotas/State/QuotaState.cs b/Examples/Core/BillingQuotas/State/QuotaState.cs similarity index 100% rename from Examples/BillingQuotas/State/QuotaState.cs rename to Examples/Core/BillingQuotas/State/QuotaState.cs diff --git a/Examples/FeatureFlags/FeatureFlags.csproj b/Examples/Core/FeatureFlags/FeatureFlags.csproj similarity index 84% rename from Examples/FeatureFlags/FeatureFlags.csproj rename to Examples/Core/FeatureFlags/FeatureFlags.csproj index 467e152..a6ca298 100644 --- a/Examples/FeatureFlags/FeatureFlags.csproj +++ b/Examples/Core/FeatureFlags/FeatureFlags.csproj @@ -8,7 +8,7 @@ - + diff --git a/Examples/FeatureFlags/Mutations/DisableFeatureMutation.cs b/Examples/Core/FeatureFlags/Mutations/DisableFeatureMutation.cs similarity index 100% rename from Examples/FeatureFlags/Mutations/DisableFeatureMutation.cs rename to Examples/Core/FeatureFlags/Mutations/DisableFeatureMutation.cs diff --git a/Examples/FeatureFlags/Mutations/EnableFeatureMutation.cs b/Examples/Core/FeatureFlags/Mutations/EnableFeatureMutation.cs similarity index 100% rename from Examples/FeatureFlags/Mutations/EnableFeatureMutation.cs rename to Examples/Core/FeatureFlags/Mutations/EnableFeatureMutation.cs diff --git a/Examples/FeatureFlags/Policies/BusinessHoursPolicy.cs b/Examples/Core/FeatureFlags/Policies/BusinessHoursPolicy.cs similarity index 100% rename from Examples/FeatureFlags/Policies/BusinessHoursPolicy.cs rename to Examples/Core/FeatureFlags/Policies/BusinessHoursPolicy.cs diff --git a/Examples/FeatureFlags/Policies/RequireTwoManApprovalPolicy.cs b/Examples/Core/FeatureFlags/Policies/RequireTwoManApprovalPolicy.cs similarity index 100% rename from Examples/FeatureFlags/Policies/RequireTwoManApprovalPolicy.cs rename to Examples/Core/FeatureFlags/Policies/RequireTwoManApprovalPolicy.cs diff --git a/Examples/FeatureFlags/Program.cs b/Examples/Core/FeatureFlags/Program.cs similarity index 100% rename from Examples/FeatureFlags/Program.cs rename to Examples/Core/FeatureFlags/Program.cs diff --git a/Examples/FeatureFlags/README.md b/Examples/Core/FeatureFlags/README.md similarity index 98% rename from Examples/FeatureFlags/README.md rename to Examples/Core/FeatureFlags/README.md index 3e1c5d9..dd682dc 100644 --- a/Examples/FeatureFlags/README.md +++ b/Examples/Core/FeatureFlags/README.md @@ -117,7 +117,7 @@ It demonstrates: ## Run ```bash -dotnet run --project Examples/FeatureFlags/FeatureFlags.csproj +dotnet run --project Examples/Core/FeatureFlags/FeatureFlags.csproj ``` ## Expected output diff --git a/Examples/FeatureFlags/Scenarios/BatchFeatureToggleScenario.cs b/Examples/Core/FeatureFlags/Scenarios/BatchFeatureToggleScenario.cs similarity index 100% rename from Examples/FeatureFlags/Scenarios/BatchFeatureToggleScenario.cs rename to Examples/Core/FeatureFlags/Scenarios/BatchFeatureToggleScenario.cs diff --git a/Examples/FeatureFlags/Scenarios/DisableLegacyCheckoutScenario.cs b/Examples/Core/FeatureFlags/Scenarios/DisableLegacyCheckoutScenario.cs similarity index 100% rename from Examples/FeatureFlags/Scenarios/DisableLegacyCheckoutScenario.cs rename to Examples/Core/FeatureFlags/Scenarios/DisableLegacyCheckoutScenario.cs diff --git a/Examples/FeatureFlags/Scenarios/EnableNewCheckoutScenario.cs b/Examples/Core/FeatureFlags/Scenarios/EnableNewCheckoutScenario.cs similarity index 100% rename from Examples/FeatureFlags/Scenarios/EnableNewCheckoutScenario.cs rename to Examples/Core/FeatureFlags/Scenarios/EnableNewCheckoutScenario.cs diff --git a/Examples/FeatureFlags/State/FeatureFlagsState.cs b/Examples/Core/FeatureFlags/State/FeatureFlagsState.cs similarity index 100% rename from Examples/FeatureFlags/State/FeatureFlagsState.cs rename to Examples/Core/FeatureFlags/State/FeatureFlagsState.cs diff --git a/Examples/IamRoles/IamRoles.csproj b/Examples/Core/IamRoles/IamRoles.csproj similarity index 84% rename from Examples/IamRoles/IamRoles.csproj rename to Examples/Core/IamRoles/IamRoles.csproj index 467e152..a6ca298 100644 --- a/Examples/IamRoles/IamRoles.csproj +++ b/Examples/Core/IamRoles/IamRoles.csproj @@ -8,7 +8,7 @@ - + diff --git a/Examples/IamRoles/Mutations/GrantUserRoleMutation.cs b/Examples/Core/IamRoles/Mutations/GrantUserRoleMutation.cs similarity index 100% rename from Examples/IamRoles/Mutations/GrantUserRoleMutation.cs rename to Examples/Core/IamRoles/Mutations/GrantUserRoleMutation.cs diff --git a/Examples/IamRoles/Mutations/RevokeUserRoleMutation.cs b/Examples/Core/IamRoles/Mutations/RevokeUserRoleMutation.cs similarity index 100% rename from Examples/IamRoles/Mutations/RevokeUserRoleMutation.cs rename to Examples/Core/IamRoles/Mutations/RevokeUserRoleMutation.cs diff --git a/Examples/IamRoles/Policies/PreventLastAdminRemovalPolicy.cs b/Examples/Core/IamRoles/Policies/PreventLastAdminRemovalPolicy.cs similarity index 100% rename from Examples/IamRoles/Policies/PreventLastAdminRemovalPolicy.cs rename to Examples/Core/IamRoles/Policies/PreventLastAdminRemovalPolicy.cs diff --git a/Examples/IamRoles/Policies/RequireTwoManApprovalPolicy.cs b/Examples/Core/IamRoles/Policies/RequireTwoManApprovalPolicy.cs similarity index 100% rename from Examples/IamRoles/Policies/RequireTwoManApprovalPolicy.cs rename to Examples/Core/IamRoles/Policies/RequireTwoManApprovalPolicy.cs diff --git a/Examples/IamRoles/Program.cs b/Examples/Core/IamRoles/Program.cs similarity index 100% rename from Examples/IamRoles/Program.cs rename to Examples/Core/IamRoles/Program.cs diff --git a/Examples/IamRoles/README.md b/Examples/Core/IamRoles/README.md similarity index 98% rename from Examples/IamRoles/README.md rename to Examples/Core/IamRoles/README.md index 91ab5b5..5503b89 100644 --- a/Examples/IamRoles/README.md +++ b/Examples/Core/IamRoles/README.md @@ -117,7 +117,7 @@ It demonstrates: ## Run ```bash -dotnet run --project Examples/IamRoles/IamRoles.csproj +dotnet run --project Examples/Core/IamRoles/IamRoles.csproj ``` ## Expected output diff --git a/Examples/IamRoles/Scenarios/BatchRoleMigrationScenario.cs b/Examples/Core/IamRoles/Scenarios/BatchRoleMigrationScenario.cs similarity index 100% rename from Examples/IamRoles/Scenarios/BatchRoleMigrationScenario.cs rename to Examples/Core/IamRoles/Scenarios/BatchRoleMigrationScenario.cs diff --git a/Examples/IamRoles/Scenarios/GrantAdminScenario.cs b/Examples/Core/IamRoles/Scenarios/GrantAdminScenario.cs similarity index 100% rename from Examples/IamRoles/Scenarios/GrantAdminScenario.cs rename to Examples/Core/IamRoles/Scenarios/GrantAdminScenario.cs diff --git a/Examples/IamRoles/Scenarios/RevokeAdminScenario.cs b/Examples/Core/IamRoles/Scenarios/RevokeAdminScenario.cs similarity index 100% rename from Examples/IamRoles/Scenarios/RevokeAdminScenario.cs rename to Examples/Core/IamRoles/Scenarios/RevokeAdminScenario.cs diff --git a/Examples/IamRoles/State/UserPermissionsState.cs b/Examples/Core/IamRoles/State/UserPermissionsState.cs similarity index 100% rename from Examples/IamRoles/State/UserPermissionsState.cs rename to Examples/Core/IamRoles/State/UserPermissionsState.cs diff --git a/Examples/WorkflowApprovals/Mutations/ApproveStepMutation.cs b/Examples/Core/WorkflowApprovals/Mutations/ApproveStepMutation.cs similarity index 100% rename from Examples/WorkflowApprovals/Mutations/ApproveStepMutation.cs rename to Examples/Core/WorkflowApprovals/Mutations/ApproveStepMutation.cs diff --git a/Examples/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs b/Examples/Core/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs similarity index 100% rename from Examples/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs rename to Examples/Core/WorkflowApprovals/Mutations/RejectWorkflowMutation.cs diff --git a/Examples/WorkflowApprovals/Mutations/StartApprovalMutation.cs b/Examples/Core/WorkflowApprovals/Mutations/StartApprovalMutation.cs similarity index 100% rename from Examples/WorkflowApprovals/Mutations/StartApprovalMutation.cs rename to Examples/Core/WorkflowApprovals/Mutations/StartApprovalMutation.cs diff --git a/Examples/WorkflowApprovals/Policies/EnforceOrderPolicy.cs b/Examples/Core/WorkflowApprovals/Policies/EnforceOrderPolicy.cs similarity index 100% rename from Examples/WorkflowApprovals/Policies/EnforceOrderPolicy.cs rename to Examples/Core/WorkflowApprovals/Policies/EnforceOrderPolicy.cs diff --git a/Examples/WorkflowApprovals/Policies/RequireManagerApprovalPolicy.cs b/Examples/Core/WorkflowApprovals/Policies/RequireManagerApprovalPolicy.cs similarity index 100% rename from Examples/WorkflowApprovals/Policies/RequireManagerApprovalPolicy.cs rename to Examples/Core/WorkflowApprovals/Policies/RequireManagerApprovalPolicy.cs diff --git a/Examples/WorkflowApprovals/Program.cs b/Examples/Core/WorkflowApprovals/Program.cs similarity index 100% rename from Examples/WorkflowApprovals/Program.cs rename to Examples/Core/WorkflowApprovals/Program.cs diff --git a/Examples/WorkflowApprovals/README.md b/Examples/Core/WorkflowApprovals/README.md similarity index 98% rename from Examples/WorkflowApprovals/README.md rename to Examples/Core/WorkflowApprovals/README.md index 8cf0c74..1655d30 100644 --- a/Examples/WorkflowApprovals/README.md +++ b/Examples/Core/WorkflowApprovals/README.md @@ -151,7 +151,7 @@ It shows: ## Run ```bash -dotnet run --project Examples/WorkflowApprovals/WorkflowApprovals.csproj +dotnet run --project Examples/Core/WorkflowApprovals/WorkflowApprovals.csproj ``` ## Expected output diff --git a/Examples/WorkflowApprovals/Scenarios/HappyPathScenario.cs b/Examples/Core/WorkflowApprovals/Scenarios/HappyPathScenario.cs similarity index 100% rename from Examples/WorkflowApprovals/Scenarios/HappyPathScenario.cs rename to Examples/Core/WorkflowApprovals/Scenarios/HappyPathScenario.cs diff --git a/Examples/WorkflowApprovals/Scenarios/RejectedScenario.cs b/Examples/Core/WorkflowApprovals/Scenarios/RejectedScenario.cs similarity index 100% rename from Examples/WorkflowApprovals/Scenarios/RejectedScenario.cs rename to Examples/Core/WorkflowApprovals/Scenarios/RejectedScenario.cs diff --git a/Examples/WorkflowApprovals/Scenarios/SideEffectsScenario.cs b/Examples/Core/WorkflowApprovals/Scenarios/SideEffectsScenario.cs similarity index 100% rename from Examples/WorkflowApprovals/Scenarios/SideEffectsScenario.cs rename to Examples/Core/WorkflowApprovals/Scenarios/SideEffectsScenario.cs diff --git a/Examples/WorkflowApprovals/State/ApprovalWorkflowState.cs b/Examples/Core/WorkflowApprovals/State/ApprovalWorkflowState.cs similarity index 100% rename from Examples/WorkflowApprovals/State/ApprovalWorkflowState.cs rename to Examples/Core/WorkflowApprovals/State/ApprovalWorkflowState.cs diff --git a/Examples/WorkflowApprovals/State/StepStatus.cs b/Examples/Core/WorkflowApprovals/State/StepStatus.cs similarity index 100% rename from Examples/WorkflowApprovals/State/StepStatus.cs rename to Examples/Core/WorkflowApprovals/State/StepStatus.cs diff --git a/Examples/WorkflowApprovals/State/WorkflowStep.cs b/Examples/Core/WorkflowApprovals/State/WorkflowStep.cs similarity index 100% rename from Examples/WorkflowApprovals/State/WorkflowStep.cs rename to Examples/Core/WorkflowApprovals/State/WorkflowStep.cs diff --git a/Examples/WorkflowApprovals/WorkflowApprovals.csproj b/Examples/Core/WorkflowApprovals/WorkflowApprovals.csproj similarity index 84% rename from Examples/WorkflowApprovals/WorkflowApprovals.csproj rename to Examples/Core/WorkflowApprovals/WorkflowApprovals.csproj index 467e152..a6ca298 100644 --- a/Examples/WorkflowApprovals/WorkflowApprovals.csproj +++ b/Examples/Core/WorkflowApprovals/WorkflowApprovals.csproj @@ -8,7 +8,7 @@ - + diff --git a/Examples/Governance/RequestLifecycle/Program.cs b/Examples/Governance/RequestLifecycle/Program.cs new file mode 100644 index 0000000..8ea111c --- /dev/null +++ b/Examples/Governance/RequestLifecycle/Program.cs @@ -0,0 +1,3 @@ +using RequestLifecycle.Scenarios; + +await GovernanceRequestLifecycleScenario.Run(); diff --git a/Examples/Governance/RequestLifecycle/README.md b/Examples/Governance/RequestLifecycle/README.md new file mode 100644 index 0000000..94a45c9 --- /dev/null +++ b/Examples/Governance/RequestLifecycle/README.md @@ -0,0 +1,42 @@ +# Governance RequestLifecycle + +This example shows the first real runtime flow built on `ModularityKit.Mutator.Governance`. + +It focuses on `MutationRequest`, `IMutationRequestStore`, and `MutationRequestLifecycleManager` rather than the core mutation engine itself. + +## What it demonstrates + +- creating governed requests with `MutationRequest.Pending(...)` and `MutationRequest.Approved(...)` +- storing requests in `InMemoryMutationRequestStore` +- moving requests through the lifecycle with `MutationRequestLifecycleManager` +- listing pending requests globally and by `StateId` +- explicit runtime paths for: + - approval + - cancellation + - expiration sweep + - external-check pending state +- inspecting `MutationRequestDecision` history after transitions + +## Key files + +- [`Program.cs`](Program.cs) +- [`Scenarios/GovernanceRequestLifecycleScenario.cs`](Scenarios/GovernanceRequestLifecycleScenario.cs) +- [`src/Governance/Abstractions/Requests/MutationRequest.cs`](../../../src/Governance/Abstractions/Requests/MutationRequest.cs) +- [`src/Governance/Abstractions/Lifecycle/IMutationRequestLifecycleManager.cs`](../../../src/Governance/Abstractions/Lifecycle/IMutationRequestLifecycleManager.cs) +- [`src/Governance/Runtime/MutationRequestLifecycleManager.cs`](../../../src/Governance/Runtime/MutationRequestLifecycleManager.cs) +- [`src/Governance/Runtime/InMemoryMutationRequestStore.cs`](../../../src/Governance/Runtime/InMemoryMutationRequestStore.cs) + +## Run + +```bash +dotnet run --project Examples/Governance/RequestLifecycle/RequestLifecycle.csproj +``` + +## Expected output + +The sample prints: + +- pending requests after submission +- pending requests filtered by state +- requests expired during the expiration sweep +- final lifecycle state and decision history for each request diff --git a/Examples/Governance/RequestLifecycle/RequestLifecycle.csproj b/Examples/Governance/RequestLifecycle/RequestLifecycle.csproj new file mode 100644 index 0000000..cd1293b --- /dev/null +++ b/Examples/Governance/RequestLifecycle/RequestLifecycle.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs b/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs new file mode 100644 index 0000000..9cb2473 --- /dev/null +++ b/Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs @@ -0,0 +1,146 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Runtime.Lifecycle; +using ModularityKit.Mutator.Governance.Runtime.Storage; + +namespace RequestLifecycle.Scenarios; + +internal static class GovernanceRequestLifecycleScenario +{ + public static async Task Run() + { + var store = new InMemoryMutationRequestStore(); + var lifecycle = new MutationRequestLifecycleManager(store); + + var submittedAt = DateTimeOffset.UtcNow; + + var quotaApprovalRequest = MutationRequest.Pending( + stateId: "tenant-42:quota", + stateType: "QuotaPolicy", + mutationType: "IncreaseQuotaMutation", + intent: new MutationIntent + { + OperationName = "IncreaseQuota", + Category = "Billing", + Description = "Increase tenant quota for month-end processing" + }, + context: MutationContext.User("alice", "Alice", "Need temporary quota uplift"), + pendingReason: PendingMutationReason.Approval, + requirements: + [ + PolicyRequirement.Approval("billing-owner", "Quota increase exceeds standard threshold") + ], + expectedStateVersion: "v12", + expiresAt: submittedAt.AddHours(6), + metadata: new Dictionary + { + ["TenantId"] = "tenant-42" + }); + + var scheduledRequest = MutationRequest.Pending( + stateId: "tenant-42:quota", + stateType: "QuotaPolicy", + mutationType: "ResetQuotaMutation", + intent: new MutationIntent + { + OperationName = "ResetQuota", + Category = "Billing", + Description = "Scheduled monthly reset" + }, + context: MutationContext.System("Queued for month-end reset"), + pendingReason: PendingMutationReason.Schedule, + expectedStateVersion: "v12", + expiresAt: submittedAt.AddMinutes(-5)); + + var externalCheckRequest = MutationRequest.Approved( + stateId: "tenant-99:flags", + stateType: "FeatureFlagState", + mutationType: "EnableFeatureMutation", + intent: new MutationIntent + { + OperationName = "EnableFeature", + Category = "Configuration", + Description = "Enable guarded rollout after dependency check" + }, + context: MutationContext.Service("release-orchestrator", "Create rollout request"), + expectedStateVersion: "v3"); + + quotaApprovalRequest = await lifecycle.Submit(quotaApprovalRequest); + scheduledRequest = await lifecycle.Submit(scheduledRequest); + externalCheckRequest = await lifecycle.Submit(externalCheckRequest); + + externalCheckRequest = await lifecycle.MoveToPending( + externalCheckRequest.RequestId, + PendingMutationReason.ExternalCheck, + MutationContext.Service("release-orchestrator", "Waiting for dependency health signal"), + reason: "Dependency health check has not completed yet."); + + PrintSection("Pending After Submission"); + PrintRequests(await lifecycle.GetPending()); + + PrintSection("Pending For tenant-42:quota"); + PrintRequests(await lifecycle.GetPendingByStateId("tenant-42:quota")); + + quotaApprovalRequest = await lifecycle.Approve( + quotaApprovalRequest.RequestId, + MutationContext.User("billing-owner", "Billing Owner", "Quota change approved"), + reason: "Temporary uplift approved for this billing cycle."); + + externalCheckRequest = await lifecycle.Cancel( + externalCheckRequest.RequestId, + MutationContext.Service("release-orchestrator", "Deployment window closed"), + reason: "Rollout canceled because the deployment window ended."); + + var expiredRequests = await lifecycle.ExpireDueRequests( + submittedAt, + MutationContext.System("Expire overdue pending requests")); + + PrintSection("Expired During Sweep"); + PrintRequests(expiredRequests); + + PrintSection("Final Request States"); + PrintRequestDetails(quotaApprovalRequest); + PrintRequestDetails(externalCheckRequest); + PrintRequestDetails(await store.Get(scheduledRequest.RequestId) ?? scheduledRequest); + } + + private static void PrintSection(string title) + { + Console.WriteLine(); + Console.WriteLine($"=== {title} ==="); + } + + private static void PrintRequests(IReadOnlyList requests) + { + foreach (var request in requests) + { + Console.WriteLine( + $"- {request.RequestId} | {request.StateId} | {request.Status} | pending: {request.PendingReason?.ToString() ?? "-"}"); + } + + if (requests.Count == 0) + Console.WriteLine("- none"); + } + + private static void PrintRequestDetails(MutationRequest request) + { + Console.WriteLine($"{request.RequestId}"); + Console.WriteLine($" state: {request.StateId}"); + Console.WriteLine($" status: {request.Status}"); + Console.WriteLine($" pending: {request.PendingReason?.ToString() ?? "-"}"); + Console.WriteLine($" expires: {request.ExpiresAt?.ToString("O") ?? "-"}"); + Console.WriteLine(" decisions:"); + + foreach (var decision in request.Decisions) + { + Console.WriteLine( + $" - {decision.Type} by {decision.Context.ActorId ?? "system"} at {decision.Timestamp:O}"); + + if (!string.IsNullOrWhiteSpace(decision.Reason)) + Console.WriteLine($" reason: {decision.Reason}"); + } + } +} diff --git a/Examples/Governance/VersionedResolution/Program.cs b/Examples/Governance/VersionedResolution/Program.cs new file mode 100644 index 0000000..800a36f --- /dev/null +++ b/Examples/Governance/VersionedResolution/Program.cs @@ -0,0 +1,3 @@ +using VersionedResolution.Scenarios; + +GovernanceVersionedResolutionScenario.Run(); diff --git a/Examples/Governance/VersionedResolution/README.md b/Examples/Governance/VersionedResolution/README.md new file mode 100644 index 0000000..a3438ba --- /dev/null +++ b/Examples/Governance/VersionedResolution/README.md @@ -0,0 +1,37 @@ +# Governance VersionedResolution + +This example shows how `MutationRequestVersionResolver` handles requests that were approved against an older state version. + +It is the direct runnable example for the semantics introduced around `ExpectedStateVersion` and stale request handling. + +## What it demonstrates + +- resolving a request when the current state version still matches +- resolving stale requests with `RejectStale` +- resolving stale requests with `RequireRenewedApproval` +- resolving stale requests with `RevalidateOnLatestState` +- inspecting the resulting lifecycle state and appended decision history + +## Key files + +- [`Program.cs`](Program.cs) +- [`Scenarios/GovernanceVersionedResolutionScenario.cs`](Scenarios/GovernanceVersionedResolutionScenario.cs) +- [`src/Governance/Runtime/MutationRequestVersionResolver.cs`](../../../src/Governance/Runtime/MutationRequestVersionResolver.cs) +- [`src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs`](../../../src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs) +- [`src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs`](../../../src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs) + +## Run + +```bash +dotnet run --project Examples/Governance/VersionedResolution/VersionedResolution.csproj +``` + +## Expected output + +The sample prints one block per resolution strategy and shows: + +- selected outcome +- whether the request was stale +- resulting request status +- updated expected version +- last decision recorded during resolution diff --git a/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs b/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs new file mode 100644 index 0000000..2d0d921 --- /dev/null +++ b/Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs @@ -0,0 +1,83 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Resolution; +using ModularityKit.Mutator.Governance.Runtime.Resolution; + +namespace VersionedResolution.Scenarios; + +internal static class GovernanceVersionedResolutionScenario +{ + public static void Run() + { + var resolver = new MutationRequestVersionResolver(); + + PrintSection("Current Version Matches Expected Version"); + PrintResolution( + resolver.Resolve( + CreateApprovedRequest("v10"), + currentStateVersion: "v10", + resolutionContext: MutationContext.User("approver-1", "Approver One", "Current version verified"), + strategy: VersionedRequestResolutionStrategy.RejectStale)); + + PrintSection("Reject Stale"); + PrintResolution( + resolver.Resolve( + CreateApprovedRequest("v10"), + currentStateVersion: "v15", + resolutionContext: MutationContext.User("approver-2", "Approver Two", "Reject stale request"), + strategy: VersionedRequestResolutionStrategy.RejectStale)); + + PrintSection("Require Renewed Approval"); + PrintResolution( + resolver.Resolve( + CreateApprovedRequest("v10"), + currentStateVersion: "v15", + resolutionContext: MutationContext.User("approver-3", "Approver Three", "Request renewed approval"), + strategy: VersionedRequestResolutionStrategy.RequireRenewedApproval)); + + PrintSection("Revalidate On Latest State"); + PrintResolution( + resolver.Resolve( + CreateApprovedRequest("v10"), + currentStateVersion: "v15", + resolutionContext: MutationContext.User("approver-4", "Approver Four", "Revalidate on the latest state"), + strategy: VersionedRequestResolutionStrategy.RevalidateOnLatestState)); + } + + private static MutationRequest CreateApprovedRequest(string expectedStateVersion) + { + return MutationRequest.Approved( + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + intent: new MutationIntent + { + OperationName = "GrantRole", + Category = "Security", + Description = "Grant elevated role to tenant operator" + }, + context: MutationContext.User("requester-1", "Requester One", "Need elevated access for incident"), + expectedStateVersion: expectedStateVersion); + } + + private static void PrintSection(string title) + { + Console.WriteLine(); + Console.WriteLine($"=== {title} ==="); + } + + private static void PrintResolution(MutationRequestVersionResolution resolution) + { + var decision = resolution.Request.Decisions[^1]; + + Console.WriteLine($"Outcome: {resolution.Outcome}"); + Console.WriteLine($"Was stale: {resolution.IsStale}"); + Console.WriteLine($"Expected version: {resolution.ExpectedStateVersion ?? "-"}"); + Console.WriteLine($"Current version: {resolution.CurrentStateVersion}"); + Console.WriteLine($"Request status: {resolution.Request.Status}"); + Console.WriteLine($"Next expected version: {resolution.Request.ExpectedStateVersion ?? "-"}"); + Console.WriteLine($"Last decision: {decision.Type}"); + Console.WriteLine($"Decision reason: {decision.Reason}"); + } +} diff --git a/Examples/Governance/VersionedResolution/VersionedResolution.csproj b/Examples/Governance/VersionedResolution/VersionedResolution.csproj new file mode 100644 index 0000000..cd1293b --- /dev/null +++ b/Examples/Governance/VersionedResolution/VersionedResolution.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/Examples/README.md b/Examples/README.md index 09cbbf3..6cf35b8 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -2,16 +2,28 @@ This folder contains runnable console apps that show how to use `ModularityKit.Mutator` in concrete domain flows. -The examples are intentionally small and focused. Each project demonstrates a different style of mutation workflow, policy enforcement, and engine usage without requiring you to read the whole library first. +The examples are split into two groups: -## What is here +- `Core/` for direct mutation engine usage +- `Governance/` for request lifecycle and governance runtime behavior + +The projects are intentionally small and focused. Each one demonstrates a different style of mutation workflow, policy enforcement, and engine usage without requiring you to read the whole library first. + +## Examples + +| Example | Focus | Readme | +| --- | --- | --- | +| `BillingQuotas` | quota changes, validation, and policy limits | [`Examples/Core/BillingQuotas/README.md`](Core/BillingQuotas/README.md) | +| `FeatureFlags` | feature toggles, audit/history, and batch execution | [`Examples/Core/FeatureFlags/README.md`](Core/FeatureFlags/README.md) | +| `IamRoles` | role changes, approval rules, and batch migration | [`Examples/Core/IamRoles/README.md`](Core/IamRoles/README.md) | +| `WorkflowApprovals` | ordered workflow state transitions and approvals | [`Examples/Core/WorkflowApprovals/README.md`](Core/WorkflowApprovals/README.md) | + +## Governance examples | Example | Focus | Readme | | --- | --- | --- | -| `BillingQuotas` | quota changes, validation, and policy limits | [`Examples/BillingQuotas/README.md`](BillingQuotas/README.md) | -| `FeatureFlags` | feature toggles, audit/history, and batch execution | [`Examples/FeatureFlags/README.md`](FeatureFlags/README.md) | -| `IamRoles` | role changes, approval rules, and batch migration | [`Examples/IamRoles/README.md`](IamRoles/README.md) | -| `WorkflowApprovals` | ordered workflow state transitions and approvals | [`Examples/WorkflowApprovals/README.md`](WorkflowApprovals/README.md) | +| `RequestLifecycle` | pending requests, lifecycle transitions, expiration, and cancellation | [`Examples/Governance/RequestLifecycle/README.md`](Governance/RequestLifecycle/README.md) | +| `VersionedResolution` | stale request handling and expected state version semantics | [`Examples/Governance/VersionedResolution/README.md`](Governance/VersionedResolution/README.md) | ## How to use these examples @@ -35,10 +47,12 @@ dotnet build ModularityKit.Mutator.slnx -c Release You can also build just one example: ```bash -dotnet build Examples/BillingQuotas/BillingQuotas.csproj -c Release -dotnet build Examples/FeatureFlags/FeatureFlags.csproj -c Release -dotnet build Examples/IamRoles/IamRoles.csproj -c Release -dotnet build Examples/WorkflowApprovals/WorkflowApprovals.csproj -c Release +dotnet build Examples/Core/BillingQuotas/BillingQuotas.csproj -c Release +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/VersionedResolution/VersionedResolution.csproj -c Release ``` ## Run @@ -48,16 +62,18 @@ Each example is a separate console app. From the repository root: ```bash -dotnet run --project Examples/BillingQuotas/BillingQuotas.csproj -dotnet run --project Examples/FeatureFlags/FeatureFlags.csproj -dotnet run --project Examples/IamRoles/IamRoles.csproj -dotnet run --project Examples/WorkflowApprovals/WorkflowApprovals.csproj +dotnet run --project Examples/Core/BillingQuotas/BillingQuotas.csproj +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/VersionedResolution/VersionedResolution.csproj ``` If you want to run one sample repeatedly while changing code, stay in its folder: ```bash -cd Examples/BillingQuotas +cd Examples/Core/BillingQuotas dotnet run ``` @@ -89,30 +105,43 @@ That layout is deliberate. It makes each sample easy to scan and keeps the mutat Shows quota management workflows such as increasing quotas, applying limits, and resetting values on schedule. It is the simplest place to look if you want a compact example of validation plus policy enforcement. -See [`BillingQuotas/README.md`](BillingQuotas/README.md). +See [`Core/BillingQuotas/README.md`](Core/BillingQuotas/README.md). ### FeatureFlags Shows feature toggle workflows with policy checks and history logging. This example is useful if you want to see how the engine behaves when you care about auditability and batch changes. -See [`FeatureFlags/README.md`](FeatureFlags/README.md). +See [`Core/FeatureFlags/README.md`](Core/FeatureFlags/README.md). ### IamRoles Shows role grant and revoke workflows with approval-style rules. This is the example to read if you care about protection against unsafe privilege changes. -See [`IamRoles/README.md`](IamRoles/README.md). +See [`Core/IamRoles/README.md`](Core/IamRoles/README.md). ### WorkflowApprovals Shows a multi-step approval process with ordered execution and rejection handling. This example is the best fit if you want to study state transitions that must happen in a strict sequence. -See [`WorkflowApprovals/README.md`](WorkflowApprovals/README.md). +See [`Core/WorkflowApprovals/README.md`](Core/WorkflowApprovals/README.md). + +### RequestLifecycle + +Shows the governance runtime as a request lifecycle system instead of an immediate execution path. This is the example to read if you want to understand pending requests, approval, cancellation, and expiration. + +See [`Governance/RequestLifecycle/README.md`](Governance/RequestLifecycle/README.md). + +### VersionedResolution + +Shows how governance resolves approved requests once the underlying state version has moved. This is the example to read if you want concrete stale request semantics. + +See [`Governance/VersionedResolution/README.md`](Governance/VersionedResolution/README.md). ## Notes - The examples are separate console applications, not libraries. -- They all reference the same mutation engine project under `src/`. +- Core examples reference the mutation engine under `src/ModularityKit.Mutator.csproj`. +- Governance examples reference `src/ModularityKit.Mutator.Governance.csproj`. - The sample code is meant to be readable and runnable, not minimal for its own sake. - Benchmarking lives in [`Benchmarks/`](../Benchmarks/README.md) and is separate from the examples. - Tests, when added, should live in [`Tests/`](../Tests/). diff --git a/ModularityKit.Mutator.slnx b/ModularityKit.Mutator.slnx index 140b420..d57cf35 100644 --- a/ModularityKit.Mutator.slnx +++ b/ModularityKit.Mutator.slnx @@ -4,10 +4,12 @@ - - - - + + + + + + diff --git a/src/Governance/Abstractions/Exceptions/InvalidMutationRequestTransitionException.cs b/src/Governance/Abstractions/Exceptions/InvalidMutationRequestTransitionException.cs new file mode 100644 index 0000000..a53205f --- /dev/null +++ b/src/Governance/Abstractions/Exceptions/InvalidMutationRequestTransitionException.cs @@ -0,0 +1,30 @@ +using ModularityKit.Mutator.Abstractions.Exceptions; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; + +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions; + +/// +/// Thrown when governance runtime attempts to apply an invalid lifecycle transition. +/// +public sealed class InvalidMutationRequestTransitionException( + string requestId, + MutationRequestStatus currentStatus, + MutationRequestStatus targetStatus) + : MutationException( + $"Mutation request '{requestId}' cannot transition from '{currentStatus}' to '{targetStatus}'.") +{ + /// + /// Stable identifier of the governed mutation request. + /// + public string RequestId { get; } = requestId; + + /// + /// Current lifecycle status recorded on the request. + /// + public MutationRequestStatus CurrentStatus { get; } = currentStatus; + + /// + /// Target lifecycle status requested by the runtime operation. + /// + public MutationRequestStatus TargetStatus { get; } = targetStatus; +} diff --git a/src/Governance/Abstractions/Exceptions/MutationRequestNotFoundException.cs b/src/Governance/Abstractions/Exceptions/MutationRequestNotFoundException.cs new file mode 100644 index 0000000..5b02063 --- /dev/null +++ b/src/Governance/Abstractions/Exceptions/MutationRequestNotFoundException.cs @@ -0,0 +1,15 @@ +using ModularityKit.Mutator.Abstractions.Exceptions; + +namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions; + +/// +/// Thrown when governance runtime cannot find a mutation request by its stable identifier. +/// +public sealed class MutationRequestNotFoundException(string requestId) + : MutationException($"Mutation request '{requestId}' was not found.") +{ + /// + /// Stable identifier of the missing request. + /// + public string RequestId { get; } = requestId; +} diff --git a/src/Governance/Abstractions/Lifecycle/IMutationRequestLifecycleManager.cs b/src/Governance/Abstractions/Lifecycle/IMutationRequestLifecycleManager.cs new file mode 100644 index 0000000..a4f9bb2 --- /dev/null +++ b/src/Governance/Abstractions/Lifecycle/IMutationRequestLifecycleManager.cs @@ -0,0 +1,113 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Requests; + +namespace ModularityKit.Mutator.Governance.Abstractions.Lifecycle; + +/// +/// Moves governed mutation requests through the runtime pending lifecycle. +/// +public interface IMutationRequestLifecycleManager +{ + /// + /// Stores a newly created request in governance persistence. + /// + Task Submit( + MutationRequest request, + CancellationToken cancellationToken = default); + + /// + /// Moves an active request into the pending lifecycle with an explicit pending reason. + /// + Task MoveToPending( + string requestId, + PendingMutationReason pendingReason, + MutationContext decisionContext, + string? reason = null, + DateTimeOffset? expiresAt = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default); + + /// + /// Marks a request as approved and ready for execution. + /// + Task Approve( + string requestId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default); + + /// + /// Marks a request as rejected. + /// + Task Reject( + string requestId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default); + + /// + /// Cancels an active request through an explicit runtime path. + /// + Task Cancel( + string requestId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default); + + /// + /// Expires a pending request. + /// + Task Expire( + string requestId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default); + + /// + /// Expires all pending requests whose expiration time has passed. + /// + Task> ExpireDueRequests( + DateTimeOffset now, + MutationContext decisionContext, + CancellationToken cancellationToken = default); + + /// + /// Marks a request as superseded by a newer request. + /// + Task Supersede( + string requestId, + string supersedingRequestId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default); + + /// + /// Marks an approved request as executed. + /// + Task MarkExecuted( + string requestId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default); + + /// + /// Lists pending requests, optionally filtered by reason. + /// + Task> GetPending( + PendingMutationReason? reason = null, + CancellationToken cancellationToken = default); + + /// + /// Lists pending requests for a specific state, optionally filtered by reason. + /// + Task> GetPendingByStateId( + string stateId, + PendingMutationReason? reason = null, + CancellationToken cancellationToken = default); +} diff --git a/src/Governance/Abstractions/Lifecycle/MutationRequestStatus.cs b/src/Governance/Abstractions/Lifecycle/MutationRequestStatus.cs index 6d352c6..8532d00 100644 --- a/src/Governance/Abstractions/Lifecycle/MutationRequestStatus.cs +++ b/src/Governance/Abstractions/Lifecycle/MutationRequestStatus.cs @@ -1,4 +1,4 @@ -namespace ModularityKit.Mutator.Governance; +namespace ModularityKit.Mutator.Governance.Abstractions.Lifecycle; /// /// Represents the lifecycle status of governed mutation request. diff --git a/src/Governance/Abstractions/Lifecycle/PendingMutationReason.cs b/src/Governance/Abstractions/Lifecycle/PendingMutationReason.cs index dfc5270..b9d70e5 100644 --- a/src/Governance/Abstractions/Lifecycle/PendingMutationReason.cs +++ b/src/Governance/Abstractions/Lifecycle/PendingMutationReason.cs @@ -1,4 +1,4 @@ -namespace ModularityKit.Mutator.Governance; +namespace ModularityKit.Mutator.Governance.Abstractions.Lifecycle; /// /// Describes why a mutation request cannot execute immediately. diff --git a/src/Governance/Abstractions/Requests/MutationRequest.cs b/src/Governance/Abstractions/Requests/MutationRequest.cs index 94dbeb7..ab8ac40 100644 --- a/src/Governance/Abstractions/Requests/MutationRequest.cs +++ b/src/Governance/Abstractions/Requests/MutationRequest.cs @@ -1,8 +1,9 @@ using ModularityKit.Mutator.Abstractions.Context; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; -namespace ModularityKit.Mutator.Governance; +namespace ModularityKit.Mutator.Governance.Abstractions.Requests; /// /// Represents a governed mutation request that may execute immediately or enter a pending lifecycle. @@ -117,7 +118,11 @@ public static MutationRequest Pending( MutationRequestDecision.Create( MutationRequestDecisionType.Submitted, context, - reason: context.Reason) + reason: context.Reason), + MutationRequestDecision.Create( + MutationRequestDecisionType.Pending, + context, + reason: $"Request entered pending lifecycle for reason '{pendingReason}'.") ] }; } diff --git a/src/Governance/Abstractions/Requests/MutationRequestDecision.cs b/src/Governance/Abstractions/Requests/MutationRequestDecision.cs index 64bb447..a241a1b 100644 --- a/src/Governance/Abstractions/Requests/MutationRequestDecision.cs +++ b/src/Governance/Abstractions/Requests/MutationRequestDecision.cs @@ -1,6 +1,6 @@ using ModularityKit.Mutator.Abstractions.Context; -namespace ModularityKit.Mutator.Governance; +namespace ModularityKit.Mutator.Governance.Abstractions.Requests; /// /// Captures a single decision or lifecycle transition applied to a mutation request. diff --git a/src/Governance/Abstractions/Requests/MutationRequestDecisionType.cs b/src/Governance/Abstractions/Requests/MutationRequestDecisionType.cs index ebe7eda..8680681 100644 --- a/src/Governance/Abstractions/Requests/MutationRequestDecisionType.cs +++ b/src/Governance/Abstractions/Requests/MutationRequestDecisionType.cs @@ -1,4 +1,4 @@ -namespace ModularityKit.Mutator.Governance; +namespace ModularityKit.Mutator.Governance.Abstractions.Requests; /// /// Represents a governance decision taken against a mutation request. @@ -6,10 +6,15 @@ namespace ModularityKit.Mutator.Governance; public enum MutationRequestDecisionType { Submitted = 0, - Approved = 1, - Rejected = 2, - Canceled = 3, - Expired = 4, - Superseded = 5, - Executed = 6 + Pending = 1, + Approved = 2, + Rejected = 3, + Canceled = 4, + Expired = 5, + Superseded = 6, + Executed = 7, + VersionValidated = 8, + RevalidationRequired = 9, + RenewedApprovalRequired = 10, + RejectedAsStale = 11 } diff --git a/src/Governance/Abstractions/Resolution/IMutationRequestVersionResolver.cs b/src/Governance/Abstractions/Resolution/IMutationRequestVersionResolver.cs new file mode 100644 index 0000000..e61c8e0 --- /dev/null +++ b/src/Governance/Abstractions/Resolution/IMutationRequestVersionResolver.cs @@ -0,0 +1,20 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Resolution; +using ModularityKit.Mutator.Governance.Abstractions.Requests; + +namespace ModularityKit.Mutator.Governance.Abstractions.Resolution; + +/// +/// Resolves a governed mutation request against the current state version before execution. +/// +public interface IMutationRequestVersionResolver +{ + /// + /// Resolves the request against the current state version using the selected stale-resolution strategy. + /// + MutationRequestVersionResolution Resolve( + MutationRequest request, + string currentStateVersion, + MutationContext resolutionContext, + VersionedRequestResolutionStrategy strategy); +} diff --git a/src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs b/src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs new file mode 100644 index 0000000..6720c85 --- /dev/null +++ b/src/Governance/Abstractions/Resolution/MutationRequestVersionResolution.cs @@ -0,0 +1,34 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests; + +namespace ModularityKit.Mutator.Governance.Abstractions.Resolution; + +/// +/// Represents the result of resolving a mutation request against the current state version. +/// +public sealed record MutationRequestVersionResolution +{ + /// + /// Updated mutation request after version-aware resolution. + /// + public MutationRequest Request { get; init; } = null!; + + /// + /// Outcome selected by the governance runtime after comparing expected and current versions. + /// + public MutationRequestVersionResolutionOutcome Outcome { get; init; } + + /// + /// Expected version captured on the request before resolution. + /// + public string? ExpectedStateVersion { get; init; } + + /// + /// Current version observed at resolution time. + /// + public string CurrentStateVersion { get; init; } = string.Empty; + + /// + /// Indicates whether the current version differs from the original expected version. + /// + public bool IsStale { get; init; } +} diff --git a/src/Governance/Abstractions/Resolution/MutationRequestVersionResolutionOutcome.cs b/src/Governance/Abstractions/Resolution/MutationRequestVersionResolutionOutcome.cs new file mode 100644 index 0000000..67f8eb7 --- /dev/null +++ b/src/Governance/Abstractions/Resolution/MutationRequestVersionResolutionOutcome.cs @@ -0,0 +1,12 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Resolution; + +/// +/// Describes the outcome of version-aware request resolution. +/// +public enum MutationRequestVersionResolutionOutcome +{ + ExecuteApprovedVersion = 0, + RevalidateOnLatestState = 1, + RejectedAsStale = 2, + RequiresRenewedApproval = 3 +} diff --git a/src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs b/src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs new file mode 100644 index 0000000..d550484 --- /dev/null +++ b/src/Governance/Abstractions/Resolution/VersionedRequestResolutionStrategy.cs @@ -0,0 +1,11 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Resolution; + +/// +/// Strategy to apply when a mutation request is resolved against a newer state version than expected. +/// +public enum VersionedRequestResolutionStrategy +{ + RejectStale = 0, + RequireRenewedApproval = 1, + RevalidateOnLatestState = 2 +} diff --git a/src/Governance/Abstractions/Storage/IMutationRequestStore.cs b/src/Governance/Abstractions/Storage/IMutationRequestStore.cs index f3beffd..1d4437d 100644 --- a/src/Governance/Abstractions/Storage/IMutationRequestStore.cs +++ b/src/Governance/Abstractions/Storage/IMutationRequestStore.cs @@ -1,4 +1,7 @@ -namespace ModularityKit.Mutator.Governance; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Requests; + +namespace ModularityKit.Mutator.Governance.Abstractions.Storage; /// /// Stores and retrieves governed mutation requests. @@ -8,28 +11,36 @@ public interface IMutationRequestStore /// /// Stores or updates a mutation request. /// - Task StoreAsync( + Task Store( MutationRequest request, CancellationToken cancellationToken = default); /// /// Retrieves a single mutation request by its stable identifier. /// - Task GetAsync( + Task Get( string requestId, CancellationToken cancellationToken = default); /// /// Retrieves all requests for a given state. /// - Task> GetByStateIdAsync( + Task> GetByStateId( string stateId, CancellationToken cancellationToken = default); + /// + /// Retrieves pending requests for a given state, optionally filtered by pending reason. + /// + Task> GetPendingByStateId( + string stateId, + PendingMutationReason? reason = null, + CancellationToken cancellationToken = default); + /// /// Retrieves pending requests, optionally filtered by pending reason. /// - Task> GetPendingAsync( + Task> GetPending( PendingMutationReason? reason = null, CancellationToken cancellationToken = default); } diff --git a/src/Governance/README.md b/src/Governance/README.md index d187f07..6bf886a 100644 --- a/src/Governance/README.md +++ b/src/Governance/README.md @@ -10,7 +10,8 @@ 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 - **Request Storage Contracts** - define a persistence seam for governance-oriented stores -- **In-Memory Runtime Support** - provide a lightweight request store for development and tests +- **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 @@ -21,6 +22,8 @@ The package defines governance-first abstractions under: - `Abstractions/Requests` - `Abstractions/Lifecycle` - `Abstractions/Storage` +- `Abstractions/Resolution` +- `Abstractions/Exceptions` Key types: @@ -30,12 +33,21 @@ Key types: - `MutationRequestStatus` - `PendingMutationReason` - `IMutationRequestStore` +- `IMutationRequestLifecycleManager` +- `IMutationRequestVersionResolver` +- `MutationRequestVersionResolution` +- `MutationRequestVersionResolutionOutcome` +- `VersionedRequestResolutionStrategy` +- `MutationRequestNotFoundException` +- `InvalidMutationRequestTransitionException` ### Runtime The initial runtime layer currently provides: -- `InMemoryMutationRequestStore` +- `Runtime/Storage/InMemoryMutationRequestStore` +- `Runtime/Lifecycle/MutationRequestLifecycleManager` +- `Runtime/Resolution/MutationRequestVersionResolver` This keeps the first version small while leaving room for later persistence providers such as Entity Framework Core or PostgreSQL-backed governance stores. diff --git a/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleManager.cs b/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleManager.cs new file mode 100644 index 0000000..e990ece --- /dev/null +++ b/src/Governance/Runtime/Lifecycle/MutationRequestLifecycleManager.cs @@ -0,0 +1,335 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Exceptions; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Storage; + +namespace ModularityKit.Mutator.Governance.Runtime.Lifecycle; + +/// +/// Applies explicit runtime transitions to governed mutation requests and persists decision history. +/// +public sealed class MutationRequestLifecycleManager(IMutationRequestStore requestStore) : IMutationRequestLifecycleManager +{ + private readonly IMutationRequestStore _requestStore = requestStore ?? throw new ArgumentNullException(nameof(requestStore)); + + public async Task Submit( + MutationRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + await _requestStore.Store(request, cancellationToken).ConfigureAwait(false); + return request; + } + + public Task MoveToPending( + string requestId, + PendingMutationReason pendingReason, + MutationContext decisionContext, + string? reason = null, + DateTimeOffset? expiresAt = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default) + { + return Transition( + requestId, + MutationRequestStatus.Pending, + MutationRequestDecisionType.Pending, + decisionContext, + reason, + request => request with + { + PendingReason = pendingReason, + ExpiresAt = expiresAt + }, + metadata, + cancellationToken); + } + + public Task Approve( + string requestId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default) + { + return Transition( + requestId, + MutationRequestStatus.Approved, + MutationRequestDecisionType.Approved, + decisionContext, + reason, + request => request with + { + PendingReason = null + }, + metadata, + cancellationToken); + } + + public Task Reject( + string requestId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default) + { + return Transition( + requestId, + MutationRequestStatus.Rejected, + MutationRequestDecisionType.Rejected, + decisionContext, + reason, + ClearPendingState, + metadata, + cancellationToken); + } + + public Task Cancel( + string requestId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default) + { + return Transition( + requestId, + MutationRequestStatus.Canceled, + MutationRequestDecisionType.Canceled, + decisionContext, + reason, + ClearPendingState, + metadata, + cancellationToken); + } + + public Task Expire( + string requestId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default) + { + return Transition( + requestId, + MutationRequestStatus.Expired, + MutationRequestDecisionType.Expired, + decisionContext, + reason, + ClearPendingState, + metadata, + cancellationToken); + } + + public async Task> ExpireDueRequests( + DateTimeOffset now, + MutationContext decisionContext, + CancellationToken cancellationToken = default) + { + var pendingRequests = await _requestStore + .GetPending(cancellationToken: cancellationToken) + .ConfigureAwait(false); + + var expiredRequests = new List(); + + foreach (var request in pendingRequests) + { + if (request.ExpiresAt is null || request.ExpiresAt > now) + continue; + + var reason = $"Pending request expired at '{request.ExpiresAt:O}'."; + + var expiredRequest = await Expire( + request.RequestId, + decisionContext, + reason, + cancellationToken: cancellationToken).ConfigureAwait(false); + + expiredRequests.Add(expiredRequest); + } + + return expiredRequests; + } + + public Task Supersede( + string requestId, + string supersedingRequestId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(supersedingRequestId)) + throw new ArgumentException("Superseding request ID is required.", nameof(supersedingRequestId)); + + var transitionMetadata = MergeMetadata( + metadata, + new Dictionary + { + ["SupersedingRequestId"] = supersedingRequestId + }); + + return Transition( + requestId, + MutationRequestStatus.Superseded, + MutationRequestDecisionType.Superseded, + decisionContext, + reason ?? $"Superseded by request '{supersedingRequestId}'.", + ClearPendingState, + transitionMetadata, + cancellationToken); + } + + public Task MarkExecuted( + string requestId, + MutationContext decisionContext, + string? reason = null, + IReadOnlyDictionary? metadata = null, + CancellationToken cancellationToken = default) + { + return Transition( + requestId, + MutationRequestStatus.Executed, + MutationRequestDecisionType.Executed, + decisionContext, + reason, + ClearPendingState, + metadata, + cancellationToken); + } + + public Task> GetPending( + PendingMutationReason? reason = null, + CancellationToken cancellationToken = default) + { + return _requestStore.GetPending(reason, cancellationToken); + } + + public Task> GetPendingByStateId( + string stateId, + PendingMutationReason? reason = null, + CancellationToken cancellationToken = default) + { + return _requestStore.GetPendingByStateId(stateId, reason, cancellationToken); + } + + private async Task Transition( + string requestId, + MutationRequestStatus targetStatus, + MutationRequestDecisionType decisionType, + MutationContext decisionContext, + string? reason, + Func applyState, + IReadOnlyDictionary? metadata, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(requestId)) + throw new ArgumentException("Request ID is required.", nameof(requestId)); + + ArgumentNullException.ThrowIfNull(decisionContext); + ArgumentNullException.ThrowIfNull(applyState); + + var request = await GetRequired(requestId, cancellationToken).ConfigureAwait(false); + + ValidateTransition(request.Status, targetStatus, request.RequestId); + + var decision = MutationRequestDecision.Create( + decisionType, + decisionContext, + reason, + metadata); + + var updatedRequest = applyState(request) with + { + Status = targetStatus, + UpdatedAt = decision.Timestamp, + Decisions = [.. request.Decisions, decision] + }; + + await _requestStore.Store(updatedRequest, cancellationToken).ConfigureAwait(false); + return updatedRequest; + } + + private async Task GetRequired( + string requestId, + CancellationToken cancellationToken) + { + var request = await _requestStore.Get(requestId, cancellationToken).ConfigureAwait(false); + + if (request is null) + throw new MutationRequestNotFoundException(requestId); + + return request; + } + + private static void ValidateTransition( + MutationRequestStatus currentStatus, + MutationRequestStatus targetStatus, + string requestId) + { + if (currentStatus == targetStatus) + throw new InvalidMutationRequestTransitionException(requestId, currentStatus, targetStatus); + + var isValid = currentStatus switch + { + MutationRequestStatus.Created => targetStatus is + MutationRequestStatus.Pending or + MutationRequestStatus.Approved or + MutationRequestStatus.Canceled or + MutationRequestStatus.Superseded, + MutationRequestStatus.Pending => targetStatus is + MutationRequestStatus.Approved or + MutationRequestStatus.Rejected or + MutationRequestStatus.Canceled or + MutationRequestStatus.Expired or + MutationRequestStatus.Superseded, + MutationRequestStatus.Approved => targetStatus is + MutationRequestStatus.Pending or + MutationRequestStatus.Rejected or + MutationRequestStatus.Canceled or + MutationRequestStatus.Superseded or + MutationRequestStatus.Executed, + MutationRequestStatus.Rejected => false, + MutationRequestStatus.Canceled => false, + MutationRequestStatus.Expired => false, + MutationRequestStatus.Superseded => false, + MutationRequestStatus.Executed => false, + _ => false + }; + + if (!isValid) + throw new InvalidMutationRequestTransitionException(requestId, currentStatus, targetStatus); + } + + private static MutationRequest ClearPendingState(MutationRequest request) + { + return request with + { + PendingReason = null, + ExpiresAt = null + }; + } + + private static IReadOnlyDictionary MergeMetadata( + IReadOnlyDictionary? metadata, + IReadOnlyDictionary appended) + { + var merged = new Dictionary(); + + if (metadata is not null) + { + foreach (var pair in metadata) + { + merged[pair.Key] = pair.Value; + } + } + + foreach (var pair in appended) + { + merged[pair.Key] = pair.Value; + } + + return merged; + } +} diff --git a/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs b/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs new file mode 100644 index 0000000..9b3270f --- /dev/null +++ b/src/Governance/Runtime/Resolution/MutationRequestVersionResolver.cs @@ -0,0 +1,184 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Resolution; + +namespace ModularityKit.Mutator.Governance.Runtime.Resolution; + +/// +/// Applies explicit version-aware resolution semantics to governed mutation requests. +/// +public sealed class MutationRequestVersionResolver : IMutationRequestVersionResolver +{ + public MutationRequestVersionResolution Resolve( + MutationRequest request, + string currentStateVersion, + MutationContext resolutionContext, + VersionedRequestResolutionStrategy strategy) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(resolutionContext); + + if (string.IsNullOrWhiteSpace(currentStateVersion)) + throw new ArgumentException("Current state version is required.", nameof(currentStateVersion)); + + var expectedStateVersion = request.ExpectedStateVersion; + var isStale = !string.IsNullOrWhiteSpace(expectedStateVersion) && + !string.Equals(expectedStateVersion, currentStateVersion, StringComparison.Ordinal); + + if (!isStale) + { + var validatedDecision = MutationRequestDecision.Create( + MutationRequestDecisionType.VersionValidated, + resolutionContext, + reason: string.IsNullOrWhiteSpace(expectedStateVersion) + ? "No expected state version was provided. Request can proceed." + : $"State version '{currentStateVersion}' matches the expected version.", + metadata: CreateVersionMetadata(expectedStateVersion, currentStateVersion)); + + return new MutationRequestVersionResolution + { + Request = AppendDecision(request, validatedDecision), + Outcome = MutationRequestVersionResolutionOutcome.ExecuteApprovedVersion, + ExpectedStateVersion = expectedStateVersion, + CurrentStateVersion = currentStateVersion, + IsStale = false + }; + } + + return strategy switch + { + VersionedRequestResolutionStrategy.RejectStale => BuildRejectedAsStale( + request, + currentStateVersion, + resolutionContext), + VersionedRequestResolutionStrategy.RequireRenewedApproval => BuildRenewedApprovalRequired( + request, + currentStateVersion, + resolutionContext), + VersionedRequestResolutionStrategy.RevalidateOnLatestState => BuildRevalidationRequired( + request, + currentStateVersion, + resolutionContext), + _ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown stale-resolution strategy.") + }; + } + + private static MutationRequestVersionResolution BuildRejectedAsStale( + MutationRequest request, + string currentStateVersion, + MutationContext resolutionContext) + { + var decision = MutationRequestDecision.Create( + MutationRequestDecisionType.RejectedAsStale, + resolutionContext, + reason: BuildStaleReason(request.ExpectedStateVersion!, currentStateVersion), + metadata: CreateVersionMetadata(request.ExpectedStateVersion, currentStateVersion)); + + var updatedRequest = AppendDecision( + request with + { + Status = MutationRequestStatus.Rejected, + PendingReason = null, + UpdatedAt = decision.Timestamp + }, + decision); + + return new MutationRequestVersionResolution + { + Request = updatedRequest, + Outcome = MutationRequestVersionResolutionOutcome.RejectedAsStale, + ExpectedStateVersion = request.ExpectedStateVersion, + CurrentStateVersion = currentStateVersion, + IsStale = true + }; + } + + private static MutationRequestVersionResolution BuildRenewedApprovalRequired( + MutationRequest request, + string currentStateVersion, + MutationContext resolutionContext) + { + var decision = MutationRequestDecision.Create( + MutationRequestDecisionType.RenewedApprovalRequired, + resolutionContext, + reason: BuildStaleReason(request.ExpectedStateVersion!, currentStateVersion), + metadata: CreateVersionMetadata(request.ExpectedStateVersion, currentStateVersion)); + + var updatedRequest = AppendDecision( + request with + { + Status = MutationRequestStatus.Pending, + PendingReason = PendingMutationReason.Approval, + ExpectedStateVersion = currentStateVersion, + UpdatedAt = decision.Timestamp + }, + decision); + + return new MutationRequestVersionResolution + { + Request = updatedRequest, + Outcome = MutationRequestVersionResolutionOutcome.RequiresRenewedApproval, + ExpectedStateVersion = request.ExpectedStateVersion, + CurrentStateVersion = currentStateVersion, + IsStale = true + }; + } + + private static MutationRequestVersionResolution BuildRevalidationRequired( + MutationRequest request, + string currentStateVersion, + MutationContext resolutionContext) + { + var decision = MutationRequestDecision.Create( + MutationRequestDecisionType.RevalidationRequired, + resolutionContext, + reason: BuildStaleReason(request.ExpectedStateVersion!, currentStateVersion), + metadata: CreateVersionMetadata(request.ExpectedStateVersion, currentStateVersion)); + + var updatedRequest = AppendDecision( + request with + { + Status = MutationRequestStatus.Approved, + PendingReason = null, + ExpectedStateVersion = currentStateVersion, + UpdatedAt = decision.Timestamp + }, + decision); + + return new MutationRequestVersionResolution + { + Request = updatedRequest, + Outcome = MutationRequestVersionResolutionOutcome.RevalidateOnLatestState, + ExpectedStateVersion = request.ExpectedStateVersion, + CurrentStateVersion = currentStateVersion, + IsStale = true + }; + } + + private static MutationRequest AppendDecision( + MutationRequest request, + MutationRequestDecision decision) + { + return request with + { + Decisions = [.. request.Decisions, decision] + }; + } + + private static string BuildStaleReason(string expectedStateVersion, string currentStateVersion) + { + return $"Request expected state version '{expectedStateVersion}' but current version is '{currentStateVersion}'."; + } + + private static IReadOnlyDictionary CreateVersionMetadata( + string? expectedStateVersion, + string currentStateVersion) + { + return new Dictionary + { + ["ExpectedStateVersion"] = expectedStateVersion ?? string.Empty, + ["CurrentStateVersion"] = currentStateVersion + }; + } +} diff --git a/src/Governance/Runtime/InMemoryMutationRequestStore.cs b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs similarity index 61% rename from src/Governance/Runtime/InMemoryMutationRequestStore.cs rename to src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs index 761293b..9cc2e2c 100644 --- a/src/Governance/Runtime/InMemoryMutationRequestStore.cs +++ b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs @@ -1,6 +1,8 @@ -using ModularityKit.Mutator.Governance; +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle; +using ModularityKit.Mutator.Governance.Abstractions.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Storage; -namespace ModularityKit.Mutator.Governance.Runtime; +namespace ModularityKit.Mutator.Governance.Runtime.Storage; /// /// In-memory store for governance mutation requests. @@ -11,7 +13,7 @@ public sealed class InMemoryMutationRequestStore : IMutationRequestStore private readonly Dictionary _requests = new(); private readonly Lock _lock = new(); - public Task StoreAsync( + public Task Store( MutationRequest request, CancellationToken cancellationToken = default) { @@ -25,7 +27,7 @@ public Task StoreAsync( return Task.CompletedTask; } - public Task GetAsync( + public Task Get( string requestId, CancellationToken cancellationToken = default) { @@ -36,7 +38,7 @@ public Task StoreAsync( } } - public Task> GetByStateIdAsync( + public Task> GetByStateId( string stateId, CancellationToken cancellationToken = default) { @@ -51,7 +53,7 @@ public Task> GetByStateIdAsync( } } - public Task> GetPendingAsync( + public Task> GetPending( PendingMutationReason? reason = null, CancellationToken cancellationToken = default) { @@ -67,4 +69,23 @@ public Task> GetPendingAsync( return Task.FromResult>(requests); } } + + public Task> GetPendingByStateId( + string stateId, + PendingMutationReason? reason = null, + CancellationToken cancellationToken = default) + { + lock (_lock) + { + var requests = _requests.Values + .Where(request => + request.StateId == stateId && + request.Status == MutationRequestStatus.Pending && + (reason is null || request.PendingReason == reason)) + .OrderBy(request => request.CreatedAt) + .ToList(); + + return Task.FromResult>(requests); + } + } } diff --git a/src/ModularityKit.Mutator.Governance.csproj b/src/ModularityKit.Mutator.Governance.csproj index a8b79c0..edcd558 100644 --- a/src/ModularityKit.Mutator.Governance.csproj +++ b/src/ModularityKit.Mutator.Governance.csproj @@ -5,6 +5,7 @@ enable enable false + ModularityKit.Mutator