diff --git a/Docs/Decision/Adr/ADR_033_Governance_Query_Model_Decomposition.md b/Docs/Decision/Adr/ADR_033_Governance_Query_Model_Decomposition.md new file mode 100644 index 0000000..56aa183 --- /dev/null +++ b/Docs/Decision/Adr/ADR_033_Governance_Query_Model_Decomposition.md @@ -0,0 +1,86 @@ +# ADR-033: Governance Query Model Decomposition + +## Tag +#adr_033 + +## Status +Accepted + +## Date +2026-06-30 + +## Scope +ModularityKit.Mutator.Governance.Abstractions.Queries + +## Context + +ADR-026 established that governance needs storage agnostic query API, but it intentionally left the exact query object model open. + +As the query surface grew, `MutationRequestQuery` started to accumulate several distinct concerns: + +- request and state identity filters +- actor filters +- intent classification filters such as category, tags, metadata, and blast radius +- lifecycle and decision-history filters +- approval oriented and decision oriented projections adjacent to the same flat namespace + +Without more explicit structure, the query model would keep growing as one wide record with loosely related fields. That creates several problems: + +- the request query type becomes harder to read and evolve +- approval and decision query types remain mixed into the same flat model namespace +- future additions such as risk level or execution specific filters would further flatten unrelated concepts into one type + +## Decision + +The governance query abstractions should be decomposed into grouped models and organized by query concern. + +Request query shape: + +- `MutationRequestQuery` remains the root request query contract +- request query filters are grouped into explicit components: + - `Scope` + - `Actor` + - `Intent` + - `Metadata` + - `Lifecycle` + - `TimeRange` +- request query presets move into `MutationRequestQueries` + +Namespace and folder organization: + +- request query abstractions live under `...Queries.Model.Requests` +- request query filter types live under `...Queries.Model.Requests.Filters` +- approval query types live under `...Queries.Model.Approvals` +- decision query types live under `...Queries.Model.Decisions` + +Evaluation semantics: + +- query evaluators compose groupspecific matches rather than expanding one flat request query type indefinitely +- approval and decision query types continue to depend on the request query contract where needed, but remain organized as distinct concerns + +## Design Rationale + +- The request query contract stays explicit without becoming one ever growing bag of filters. +- Filter grouping makes new additions easier to place without weakening the model. +- Approval and decision queries are part of the same read side, but not the same concern as request filters, so they should not share one flat namespace. +- Moving presets out of `MutationRequestQuery` keeps the model closer to a DTO and avoids mixing data shape with convenience factories. + +## Consequences + +### Positive + +- The query model is easier to scan and extend. +- Filter placement becomes more disciplined as the governance read side grows. +- Namespace organization better reflects actual query responsibilities. +- The query API stays storage agnostic while becoming more explicit internally. + +### Negative + +- Consumers need a small amount of additional object construction when building request queries. +- Existing code that referenced the old flat model or flat namespaces must be updated. +- Some changes that are semantically query related now span several small types instead of one file. + +## Related ADRs + +- ADR-026: Governance Request Query API +- ADR-030: Governance Redis Request Storage and Query Strategy diff --git a/Docs/Decision/listadr.md b/Docs/Decision/listadr.md index 08d6567..5c089ae 100644 --- a/Docs/Decision/listadr.md +++ b/Docs/Decision/listadr.md @@ -48,5 +48,6 @@ These ADRs describe the `ModularityKit.Mutator.Governance` extension layer and i | ADR-030 | Governance Redis Request Storage and Query Strategy | [ADR-030](Adr/ADR_030_Governance_Redis_Request_Storage_and_Query_Strategy.md) | | ADR-031 | Governance Redis Serialization and Document Compatibility | [ADR-031](Adr/ADR_031_Governance_Redis_Serialization_and_Document_Compatibility.md) | | ADR-032 | Governance Redis Concurrency and Index Maintenance Model | [ADR-032](Adr/ADR_032_Governance_Redis_Concurrency_and_Index_Maintenance_Model.md) | +| ADR-033 | Governance Query Model Decomposition | [ADR-033](Adr/ADR_033_Governance_Query_Model_Decomposition.md) | > See individual ADRs for detailed context, decision rationale, and consequences. diff --git a/Examples/Governance/Queries/README.md b/Examples/Governance/Queries/README.md index 3b03a16..66aa96e 100644 --- a/Examples/Governance/Queries/README.md +++ b/Examples/Governance/Queries/README.md @@ -7,6 +7,7 @@ It focuses on listing governed requests, approval work, and decision history wit ## What it demonstrates - querying governed requests with `MutationRequestQuery` +- filtering by intent tags, intent metadata, request metadata, and blast radius - listing the pending approval queue through `IMutationRequestQueryStore` - listing pending requests by `PendingMutationReason` - querying requests by `StateId` and request category @@ -25,9 +26,9 @@ It focuses on listing governed requests, approval work, and decision history wit - [`Scenarios/ApprovalQueryScenario.cs`](Scenarios/ApprovalQueryScenario.cs) - [`Scenarios/DecisionQueryScenario.cs`](Scenarios/DecisionQueryScenario.cs) - [`src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs`](../../../src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs) -- [`src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs`](../../../src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs) -- [`src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs`](../../../src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs) -- [`src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs`](../../../src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs) +- [`src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs`](../../../src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs) +- [`src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQuery.cs`](../../../src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQuery.cs) +- [`src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQuery.cs`](../../../src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQuery.cs) ## Run @@ -43,6 +44,7 @@ The sample prints: - pending external-check requests - requests filtered by request category - requests filtered by state +- requests filtered by governance metadata - recent approval-driven requests - approval views filtered by approver - recent version-resolution decisions diff --git a/Examples/Governance/Queries/Scenarios/ApprovalQueryScenario.cs b/Examples/Governance/Queries/Scenarios/ApprovalQueryScenario.cs index fea182b..744ba2e 100644 --- a/Examples/Governance/Queries/Scenarios/ApprovalQueryScenario.cs +++ b/Examples/Governance/Queries/Scenarios/ApprovalQueryScenario.cs @@ -1,5 +1,5 @@ using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; namespace Queries.Scenarios; diff --git a/Examples/Governance/Queries/Scenarios/DecisionQueryScenario.cs b/Examples/Governance/Queries/Scenarios/DecisionQueryScenario.cs index 4df2150..b60554f 100644 --- a/Examples/Governance/Queries/Scenarios/DecisionQueryScenario.cs +++ b/Examples/Governance/Queries/Scenarios/DecisionQueryScenario.cs @@ -1,5 +1,5 @@ using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; namespace Queries.Scenarios; diff --git a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs index a089acd..15e796e 100644 --- a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs +++ b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs @@ -3,6 +3,8 @@ using ModularityKit.Mutator.Abstractions.Policies; using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; @@ -76,7 +78,7 @@ public static void PrintRequests(IReadOnlyList requests) Console.WriteLine("- none"); } - public static void PrintApprovals(IReadOnlyList approvals) + public static void PrintApprovals(IReadOnlyList approvals) { foreach (var approval in approvals) { @@ -88,7 +90,7 @@ public static void PrintApprovals(IReadOnlyList decisions) + public static void PrintDecisions(IReadOnlyList decisions) { foreach (var decision in decisions) { @@ -114,7 +116,13 @@ private static MutationRequest CreatePendingApprovalRequest( { OperationName = "ExampleOperation", Category = category, - Description = $"Governed request for {category.ToLowerInvariant()} flow" + Description = $"Governed request for {category.ToLowerInvariant()} flow", + Tags = new HashSet { category.ToLowerInvariant(), "approval" }, + EstimatedBlastRadius = category == "Security" ? BlastRadius.Module : BlastRadius.Single, + Metadata = new Dictionary + { + ["risk-owner"] = category == "Security" ? "platform" : "finance" + } }, context: MutationContext.User("requester", "Requester", "Need governed change"), requirements: @@ -126,6 +134,10 @@ private static MutationRequest CreatePendingApprovalRequest( RequestId = requestId, CreatedAt = createdAt, UpdatedAt = createdAt, + Metadata = new Dictionary + { + ["ticket"] = category == "Security" ? "INC-42" : "BILL-7" + }, ApprovalRequirements = [ new MutationApprovalRequirement @@ -150,7 +162,13 @@ private static MutationRequest CreateExternalCheckRequest( { OperationName = "ExampleOperation", Category = category, - Description = "Waiting for dependency validation" + Description = "Waiting for dependency validation", + Tags = new HashSet { "external-check" }, + EstimatedBlastRadius = BlastRadius.Single, + Metadata = new Dictionary + { + ["risk-owner"] = "release" + } }, context: MutationContext.Service("release-orchestrator", "Waiting for external dependency"), pendingReason: PendingMutationReason.ExternalCheck) @@ -158,7 +176,11 @@ private static MutationRequest CreateExternalCheckRequest( { RequestId = requestId, CreatedAt = createdAt, - UpdatedAt = createdAt + UpdatedAt = createdAt, + Metadata = new Dictionary + { + ["ticket"] = "REL-99" + } }; private static MutationRequest CreateRecentlyApprovedRequest( @@ -175,7 +197,13 @@ private static MutationRequest CreateRecentlyApprovedRequest( { OperationName = "ExampleOperation", Category = category, - Description = "Recently approved governed request" + Description = "Recently approved governed request", + Tags = new HashSet { category.ToLowerInvariant(), "approved" }, + EstimatedBlastRadius = category == "Security" ? BlastRadius.Module : BlastRadius.Single, + Metadata = new Dictionary + { + ["risk-owner"] = category == "Security" ? "platform" : "finance" + } }, context: MutationContext.User("requester", "Requester", "Need privileged change"), requirements: @@ -189,6 +217,10 @@ private static MutationRequest CreateRecentlyApprovedRequest( PendingReason = null, CreatedAt = approvedAt.AddMinutes(-20), UpdatedAt = approvedAt, + Metadata = new Dictionary + { + ["ticket"] = category == "Security" ? "INC-77" : "BILL-9" + }, ApprovalRequirements = [ new MutationApprovalRequirement @@ -252,7 +284,13 @@ private static MutationRequest CreateResolvedRequest( { OperationName = "ExampleOperation", Category = category, - Description = "Resolved governed request" + Description = "Resolved governed request", + Tags = new HashSet { category.ToLowerInvariant(), "resolution" }, + EstimatedBlastRadius = BlastRadius.Single, + Metadata = new Dictionary + { + ["risk-owner"] = "governance" + } }, context: MutationContext.Service("governance-runtime", "Resolve stale request")) with @@ -261,6 +299,10 @@ private static MutationRequest CreateResolvedRequest( Status = MutationRequestStatus.Approved, CreatedAt = decisionTimestamp.AddMinutes(-30), UpdatedAt = decisionTimestamp, + Metadata = new Dictionary + { + ["ticket"] = "CFG-5" + }, Decisions = [ MutationRequestDecision.Lifecycle( @@ -293,7 +335,13 @@ private static MutationRequest CreateExecutedRequest( { OperationName = "ExampleOperation", Category = category, - Description = "Executed governed request" + Description = "Executed governed request", + Tags = new HashSet { category.ToLowerInvariant(), "executed" }, + EstimatedBlastRadius = BlastRadius.Single, + Metadata = new Dictionary + { + ["risk-owner"] = "finance" + } }, context: MutationContext.Service("governance-runtime", "Execute approved request")) with @@ -303,6 +351,10 @@ private static MutationRequest CreateExecutedRequest( CreatedAt = executedAt.AddMinutes(-15), UpdatedAt = executedAt, ExecutedAt = executedAt, + Metadata = new Dictionary + { + ["ticket"] = "BILL-22" + }, Decisions = [ MutationRequestDecision.Lifecycle( diff --git a/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs b/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs index 9df4cb5..b3a223d 100644 --- a/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs +++ b/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs @@ -1,6 +1,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Abstractions.Intent; namespace Queries.Scenarios; @@ -14,19 +16,43 @@ public static async Task Run(IMutationRequestQueryStore queryStore) GovernanceQueriesSampleData.PrintSection("Pending External Check Requests"); GovernanceQueriesSampleData.PrintRequests(await queryStore.GetPendingRequestsAsync(new MutationRequestQuery { - PendingReasons = new HashSet { PendingMutationReason.ExternalCheck } + Lifecycle = new MutationRequestLifecycleFilter + { + PendingReasons = new HashSet { PendingMutationReason.ExternalCheck } + } })); GovernanceQueriesSampleData.PrintSection("Billing Requests"); GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery { - Categories = new HashSet { "Billing" } + Intent = new MutationRequestIntentFilter + { + Categories = new HashSet { "Billing" } + } })); GovernanceQueriesSampleData.PrintSection("Requests For tenant-42:roles"); GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery { - StateIds = new HashSet { "tenant-42:roles" } + Scope = new MutationRequestScopeFilter + { + StateIds = new HashSet { "tenant-42:roles" } + } + })); + + GovernanceQueriesSampleData.PrintSection("Metadata-classified Security Requests"); + GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery + { + Intent = new MutationRequestIntentFilter + { + Tags = new HashSet { "security" }, + Metadata = new Dictionary { ["risk-owner"] = "platform" }, + MinimumBlastRadiusScope = BlastRadiusScope.Module + }, + Metadata = new MutationRequestMetadataFilter + { + Values = new Dictionary { ["ticket"] = "INC-42" } + } })); GovernanceQueriesSampleData.PrintSection("Recent Approval Driven Requests"); diff --git a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs index 3cb5fd5..13a24f4 100644 --- a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs +++ b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs @@ -4,7 +4,9 @@ using ModularityKit.Mutator.Abstractions.Policies; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs index f62620f..48bc73b 100644 --- a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs @@ -73,6 +73,7 @@ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime() Assert.Equal(request.Intent.Category, roundtrip.Intent.Category); Assert.Contains("security", roundtrip.Intent.Tags); Assert.Equal(BlastRadiusScope.Module, roundtrip.Intent.EstimatedBlastRadius?.Scope); + Assert.Equal("platform", roundtrip.Intent.Metadata["risk-owner"]); Assert.Equal("security", roundtrip.Metadata["team"]); Assert.Single(roundtrip.Requirements); Assert.Single(roundtrip.ApprovalRequirements); diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs index ea78b9d..accecab 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Execution/GovernanceExecutionManagerTests.cs @@ -44,10 +44,20 @@ public async Task ExecuteApproved_executes_request_persists_resulting_version_an { OperationName = "GrantRole", Category = "Security", - Description = "Grant elevated access" + Description = "Grant elevated access", + Tags = new HashSet { "security", "incident" }, + EstimatedBlastRadius = BlastRadius.Module, + Metadata = new Dictionary + { + ["risk-owner"] = "platform" + } }, context: MutationContext.User("requester", "Requester", "Need access"), - expectedStateVersion: "v10")); + expectedStateVersion: "v10", + metadata: new Dictionary + { + ["ticket"] = "INC-42" + })); var mutation = new PromoteRoleMutation( MutationContext.User("operator-1", "Operator One", "Execute approved role promotion"), nextVersion: "v11"); @@ -78,6 +88,11 @@ public async Task ExecuteApproved_executes_request_persists_resulting_version_an Assert.Single(history.Entries); Assert.Equal(request.RequestId, auditEntries[0].Context.Metadata["GovernanceRequestId"]); Assert.Equal(request.RequestId, history.Entries[0].Context.Metadata["GovernanceRequestId"]); + Assert.Contains("security", auditEntries[0].MutationIntent.Tags); + Assert.Equal(BlastRadiusScope.Module, auditEntries[0].MutationIntent.EstimatedBlastRadius?.Scope); + Assert.Equal("platform", auditEntries[0].MutationIntent.Metadata["risk-owner"]); + Assert.Equal("platform", history.Entries[0].Intent.Metadata["risk-owner"]); + Assert.Equal("INC-42", ((IReadOnlyDictionary)auditEntries[0].Context.Metadata["GovernanceRequestMetadata"])["ticket"]); } [Fact] diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs index ce202b0..c334e6b 100644 --- a/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs +++ b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs @@ -3,7 +3,10 @@ using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; using ModularityKit.Mutator.Abstractions.Policies; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; @@ -27,7 +30,8 @@ public async Task QueryAsync_filters_requests_by_governance_dimensions() actorName: "Alice", category: "Security", tags: new HashSet { "security", "urgent" }, - metadata: new Dictionary { ["team"] = "platform" }, + intentMetadata: new Dictionary { ["team"] = "platform" }, + requestMetadata: new Dictionary { ["team"] = "platform" }, blastRadius: BlastRadius.Module, createdAt: new DateTimeOffset(2026, 6, 1, 10, 0, 0, TimeSpan.Zero), updatedAt: new DateTimeOffset(2026, 6, 1, 11, 0, 0, TimeSpan.Zero), @@ -55,7 +59,8 @@ await store.Create(CreateGovernedRequest( actorName: "Bob", category: "Billing", tags: new HashSet { "billing" }, - metadata: new Dictionary { ["team"] = "finance" }, + intentMetadata: new Dictionary { ["team"] = "finance" }, + requestMetadata: new Dictionary { ["team"] = "finance" }, blastRadius: BlastRadius.System, createdAt: new DateTimeOffset(2026, 6, 2, 10, 0, 0, TimeSpan.Zero), updatedAt: new DateTimeOffset(2026, 6, 2, 11, 0, 0, TimeSpan.Zero), @@ -73,16 +78,32 @@ await store.Create(CreateGovernedRequest( var results = await store.QueryAsync(new MutationRequestQuery { - Statuses = new HashSet { MutationRequestStatus.Pending }, - PendingReasons = new HashSet { PendingMutationReason.Approval }, - ActorIds = new HashSet { "alice" }, - Categories = new HashSet { "Security" }, - Tags = new HashSet { "security", "urgent" }, - TagMatchMode = MutationRequestTagMatchMode.All, - Metadata = new Dictionary { ["team"] = "platform" }, - MinimumBlastRadiusScope = BlastRadiusScope.Module, - CreatedFrom = new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero), - CreatedTo = new DateTimeOffset(2026, 6, 1, 23, 59, 59, TimeSpan.Zero) + Lifecycle = new MutationRequestLifecycleFilter + { + Statuses = new HashSet { MutationRequestStatus.Pending }, + PendingReasons = new HashSet { PendingMutationReason.Approval } + }, + Actor = new MutationRequestActorFilter + { + ActorIds = new HashSet { "alice" } + }, + Intent = new MutationRequestIntentFilter + { + Categories = new HashSet { "Security" }, + Tags = new HashSet { "security", "urgent" }, + TagMatchMode = MutationRequestTagMatchMode.All, + Metadata = new Dictionary { ["team"] = "platform" }, + MinimumBlastRadiusScope = BlastRadiusScope.Module + }, + Metadata = new MutationRequestMetadataFilter + { + Values = new Dictionary { ["team"] = "platform" } + }, + TimeRange = new MutationRequestTimeRangeFilter + { + CreatedFrom = new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero), + CreatedTo = new DateTimeOffset(2026, 6, 1, 23, 59, 59, TimeSpan.Zero) + } }); Assert.Single(results); @@ -111,6 +132,63 @@ await store.Create(CreateSimpleRequest( Assert.Equal(pending.RequestId, results[0].RequestId); } + [Fact] + public async Task QueryAsync_can_filter_by_intent_metadata_independently_from_request_metadata() + { + var store = new InMemoryMutationRequestStore(); + + await store.Create(CreateGovernedRequest( + requestId: "req-platform", + stateId: "tenant-42:roles", + stateType: "IamRoleState", + mutationType: "GrantRoleMutation", + actorId: "alice", + actorName: "Alice", + category: "Security", + tags: new HashSet { "security" }, + intentMetadata: new Dictionary { ["risk-owner"] = "platform" }, + requestMetadata: new Dictionary { ["ticket"] = "INC-42" }, + blastRadius: BlastRadius.Module, + createdAt: new DateTimeOffset(2026, 6, 1, 10, 0, 0, TimeSpan.Zero), + updatedAt: new DateTimeOffset(2026, 6, 1, 11, 0, 0, TimeSpan.Zero), + status: MutationRequestStatus.Pending, + pendingReason: PendingMutationReason.Approval, + decisions: [])); + + await store.Create(CreateGovernedRequest( + requestId: "req-finance", + stateId: "tenant-42:quota", + stateType: "QuotaState", + mutationType: "IncreaseQuotaMutation", + actorId: "bob", + actorName: "Bob", + category: "Billing", + tags: new HashSet { "billing" }, + intentMetadata: new Dictionary { ["risk-owner"] = "finance" }, + requestMetadata: new Dictionary { ["ticket"] = "FIN-9" }, + blastRadius: BlastRadius.Single, + createdAt: new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero), + updatedAt: new DateTimeOffset(2026, 6, 1, 12, 30, 0, TimeSpan.Zero), + status: MutationRequestStatus.Approved, + pendingReason: null, + decisions: [])); + + var results = await store.QueryAsync(new MutationRequestQuery + { + Intent = new MutationRequestIntentFilter + { + Metadata = new Dictionary { ["risk-owner"] = "platform" } + }, + Metadata = new MutationRequestMetadataFilter + { + Values = new Dictionary { ["ticket"] = "INC-42" } + } + }); + + Assert.Single(results); + Assert.Equal("req-platform", results[0].RequestId); + } + [Fact] public async Task GetPendingApprovalQueueAsync_and_GetRecentApprovalsAsync_return_approval_oriented_views() { @@ -389,7 +467,8 @@ private static MutationRequest CreateGovernedRequest( string actorName, string category, IReadOnlySet tags, - IReadOnlyDictionary metadata, + IReadOnlyDictionary intentMetadata, + IReadOnlyDictionary requestMetadata, BlastRadius blastRadius, DateTimeOffset createdAt, DateTimeOffset updatedAt, @@ -407,7 +486,7 @@ private static MutationRequest CreateGovernedRequest( OperationName = mutationType, Category = category, Tags = tags, - Metadata = metadata, + Metadata = intentMetadata, EstimatedBlastRadius = blastRadius }, Context = MutationContext.User(actorId, actorName, "Query test"), @@ -416,6 +495,6 @@ private static MutationRequest CreateGovernedRequest( CreatedAt = createdAt, UpdatedAt = updatedAt, Decisions = decisions, - Metadata = metadata + Metadata = requestMetadata }; } diff --git a/src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs b/src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs index f8c327d..810bfdb 100644 --- a/src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs +++ b/src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs @@ -1,5 +1,7 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; diff --git a/src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQuery.cs similarity index 96% rename from src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs rename to src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQuery.cs index 9b7a981..7928d2a 100644 --- a/src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs +++ b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQuery.cs @@ -1,7 +1,9 @@ using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; /// /// Defines storage-agnostic filters for approval-oriented governance queries. diff --git a/src/Governance/Abstractions/Queries/Model/MutationApprovalQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQueryEvaluator.cs similarity index 96% rename from src/Governance/Abstractions/Queries/Model/MutationApprovalQueryEvaluator.cs rename to src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQueryEvaluator.cs index af60980..72c7040 100644 --- a/src/Governance/Abstractions/Queries/Model/MutationApprovalQueryEvaluator.cs +++ b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalQueryEvaluator.cs @@ -1,7 +1,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; /// /// Evaluates approval oriented query criteria against governed mutation requests. diff --git a/src/Governance/Abstractions/Queries/Model/MutationApprovalView.cs b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalView.cs similarity index 97% rename from src/Governance/Abstractions/Queries/Model/MutationApprovalView.cs rename to src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalView.cs index 90c32bd..9ecfc65 100644 --- a/src/Governance/Abstractions/Queries/Model/MutationApprovalView.cs +++ b/src/Governance/Abstractions/Queries/Model/Approvals/MutationApprovalView.cs @@ -1,7 +1,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Approval.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; /// /// Represents one approval oriented projection from governed mutation request. diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQuery.cs similarity index 96% rename from src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs rename to src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQuery.cs index 14bcd7d..ed1734d 100644 --- a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs +++ b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQuery.cs @@ -1,6 +1,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; /// /// Defines storage agnostic filters for decision oriented governance queries. diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQueryEvaluator.cs similarity index 96% rename from src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQueryEvaluator.cs rename to src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQueryEvaluator.cs index 1a6313a..6bcb55a 100644 --- a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQueryEvaluator.cs +++ b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionQueryEvaluator.cs @@ -1,7 +1,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; /// /// Evaluates decision oriented query criteria against governed mutation requests. diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionView.cs b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionView.cs similarity index 97% rename from src/Governance/Abstractions/Queries/Model/MutationRequestDecisionView.cs rename to src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionView.cs index 6ef3212..b96ab33 100644 --- a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionView.cs +++ b/src/Governance/Abstractions/Queries/Model/Decisions/MutationRequestDecisionView.cs @@ -1,7 +1,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; /// /// Represents one decision-oriented projection from a governed mutation request. diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs b/src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs deleted file mode 100644 index 1bc0f49..0000000 --- a/src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs +++ /dev/null @@ -1,163 +0,0 @@ -using ModularityKit.Mutator.Abstractions.Intent; -using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; - -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; - -/// -/// Defines storage-agnostic filters for governed mutation request queries. -/// -public sealed record MutationRequestQuery -{ - /// - /// Specific request identifiers to include. - /// - public IReadOnlySet RequestIds { get; init; } = new HashSet(); - - /// - /// State identifiers to include. - /// - public IReadOnlySet StateIds { get; init; } = new HashSet(); - - /// - /// State types to include. - /// - public IReadOnlySet StateTypes { get; init; } = new HashSet(); - - /// - /// Mutation types to include. - /// - public IReadOnlySet MutationTypes { get; init; } = new HashSet(); - - /// - /// Actor identifiers to include. - /// - public IReadOnlySet ActorIds { get; init; } = new HashSet(); - - /// - /// Actor names to include. - /// - public IReadOnlySet ActorNames { get; init; } = new HashSet(); - - /// - /// Mutation categories to include. - /// - public IReadOnlySet Categories { get; init; } = new HashSet(); - - /// - /// Request statuses to include. - /// - public IReadOnlySet Statuses { get; init; } = new HashSet(); - - /// - /// Pending reasons to include. - /// - public IReadOnlySet PendingReasons { get; init; } = new HashSet(); - - /// - /// Tags to include from the request intent. - /// - public IReadOnlySet Tags { get; init; } = new HashSet(); - - /// - /// Tag matching strategy. - /// - public MutationRequestTagMatchMode TagMatchMode { get; init; } = MutationRequestTagMatchMode.Any; - - /// - /// Exact metadata key/value pairs to match against request metadata. - /// - public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); - - /// - /// Minimum estimated blast radius scope to include. - /// - public BlastRadiusScope? MinimumBlastRadiusScope { get; init; } - - /// - /// Maximum estimated blast radius scope to include. - /// - public BlastRadiusScope? MaximumBlastRadiusScope { get; init; } - - /// - /// Inclusive lower bound for request creation time. - /// - public DateTimeOffset? CreatedFrom { get; init; } - - /// - /// Inclusive upper bound for request creation time. - /// - public DateTimeOffset? CreatedTo { get; init; } - - /// - /// Inclusive lower bound for request update time. - /// - public DateTimeOffset? UpdatedFrom { get; init; } - - /// - /// Inclusive upper bound for request update time. - /// - public DateTimeOffset? UpdatedTo { get; init; } - - /// - /// Decision categories that must appear in the request history. - /// - public IReadOnlySet DecisionCategories { get; init; } - = new HashSet(); - - /// - /// Creates a query that targets pending requests. - /// - public static MutationRequestQuery Pending() - => new() - { - Statuses = new HashSet { MutationRequestStatus.Pending } - }; - - /// - /// Creates a query that targets the pending approval queue. - /// - public static MutationRequestQuery PendingApprovalQueue() - => new() - { - Statuses = new HashSet { MutationRequestStatus.Pending }, - PendingReasons = new HashSet { PendingMutationReason.Approval }, - DecisionCategories = new HashSet - { - MutationRequestDecisionCategory.Approval - } - }; - - /// - /// Creates a query that targets approval-driven requests that recently moved through approval. - /// - public static MutationRequestQuery RecentApprovals() - => new() - { - Statuses = new HashSet - { - MutationRequestStatus.Approved, - MutationRequestStatus.Executed - }, - DecisionCategories = new HashSet - { - MutationRequestDecisionCategory.Approval - } - }; -} - -/// -/// Controls how tag filters are evaluated. -/// -public enum MutationRequestTagMatchMode -{ - /// - /// Match requests that contain at least one of the requested tags. - /// - Any = 0, - - /// - /// Match requests that contain all requested tags. - /// - All = 1 -} diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/MutationRequestQueryEvaluator.cs deleted file mode 100644 index 379dfb4..0000000 --- a/src/Governance/Abstractions/Queries/Model/MutationRequestQueryEvaluator.cs +++ /dev/null @@ -1,157 +0,0 @@ -using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; -using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; - -namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model; - -/// -/// Evaluates query criteria against governed mutation requests. -/// -public static class MutationRequestQueryEvaluator -{ - public static bool Matches(MutationRequest request, MutationRequestQuery query) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(query); - - return MatchesRequestId(request, query) && - MatchesStateId(request, query) && - MatchesStateType(request, query) && - MatchesMutationType(request, query) && - MatchesActorId(request, query) && - MatchesActorName(request, query) && - MatchesCategory(request, query) && - MatchesStatus(request, query) && - MatchesPendingReason(request, query) && - MatchesTags(request, query) && - MatchesMetadata(request, query) && - MatchesBlastRadius(request, query) && - MatchesCreatedAt(request, query) && - MatchesUpdatedAt(request, query) && - MatchesDecisionCategories(request, query); - } - - public static bool HasApprovalActivity(MutationRequest request) - { - ArgumentNullException.ThrowIfNull(request); - - return request.ApprovalRequirements.Count > 0 || - request.Decisions.Any(decision => decision.Type.Category == MutationRequestDecisionCategory.Approval); - } - - public static DateTimeOffset GetRecentApprovalTimestamp(MutationRequest request) - { - ArgumentNullException.ThrowIfNull(request); - - var approvalDecision = request.Decisions - .Where(decision => decision.Type.Category == MutationRequestDecisionCategory.Approval && - decision.Type.Code != MutationRequestApprovalDecisionType.Requested.ToString()) - .OrderByDescending(decision => decision.Timestamp) - .FirstOrDefault(); - - return approvalDecision?.Timestamp ?? request.UpdatedAt; - } - - private static bool MatchesMetadata(MutationRequest request, MutationRequestQuery query) - => query.Metadata.Count == 0 || MatchesMetadata(request.Metadata, query.Metadata); - - private static bool MatchesRequestId(MutationRequest request, MutationRequestQuery query) - => query.RequestIds.Count == 0 || query.RequestIds.Contains(request.RequestId); - - private static bool MatchesStateId(MutationRequest request, MutationRequestQuery query) - => query.StateIds.Count == 0 || query.StateIds.Contains(request.StateId); - - private static bool MatchesStateType(MutationRequest request, MutationRequestQuery query) - => query.StateTypes.Count == 0 || query.StateTypes.Contains(request.StateType); - - private static bool MatchesMutationType(MutationRequest request, MutationRequestQuery query) - => query.MutationTypes.Count == 0 || query.MutationTypes.Contains(request.MutationType); - - private static bool MatchesActorId(MutationRequest request, MutationRequestQuery query) - => query.ActorIds.Count == 0 || - (request.Context.ActorId is not null && query.ActorIds.Contains(request.Context.ActorId)); - - private static bool MatchesActorName(MutationRequest request, MutationRequestQuery query) - => query.ActorNames.Count == 0 || - (request.Context.ActorName is not null && query.ActorNames.Contains(request.Context.ActorName)); - - private static bool MatchesCategory(MutationRequest request, MutationRequestQuery query) - => query.Categories.Count == 0 || query.Categories.Contains(request.Intent.Category); - - private static bool MatchesStatus(MutationRequest request, MutationRequestQuery query) - => query.Statuses.Count == 0 || query.Statuses.Contains(request.Status); - - private static bool MatchesPendingReason(MutationRequest request, MutationRequestQuery query) - => query.PendingReasons.Count == 0 || - (request.PendingReason is not null && query.PendingReasons.Contains(request.PendingReason.Value)); - - private static bool MatchesTags(MutationRequest request, MutationRequestQuery query) - { - if (query.Tags.Count == 0) - return true; - - var requestTags = request.Intent.Tags; - return query.TagMatchMode == MutationRequestTagMatchMode.All - ? query.Tags.All(requestTags.Contains) - : query.Tags.Any(requestTags.Contains); - } - - private static bool MatchesBlastRadius(MutationRequest request, MutationRequestQuery query) - { - if (!query.MinimumBlastRadiusScope.HasValue && !query.MaximumBlastRadiusScope.HasValue) - return true; - - var scope = request.Intent.EstimatedBlastRadius?.Scope; - if (scope is null) - return false; - - if (query.MinimumBlastRadiusScope.HasValue && scope.Value < query.MinimumBlastRadiusScope.Value) - return false; - - if (query.MaximumBlastRadiusScope.HasValue && scope.Value > query.MaximumBlastRadiusScope.Value) - return false; - - return true; - } - - private static bool MatchesCreatedAt(MutationRequest request, MutationRequestQuery query) - { - if (query.CreatedFrom.HasValue && request.CreatedAt < query.CreatedFrom.Value) - return false; - - if (query.CreatedTo.HasValue && request.CreatedAt > query.CreatedTo.Value) - return false; - - return true; - } - - private static bool MatchesUpdatedAt(MutationRequest request, MutationRequestQuery query) - { - if (query.UpdatedFrom.HasValue && request.UpdatedAt < query.UpdatedFrom.Value) - return false; - - if (query.UpdatedTo.HasValue && request.UpdatedAt > query.UpdatedTo.Value) - return false; - - return true; - } - - private static bool MatchesDecisionCategories(MutationRequest request, MutationRequestQuery query) - => query.DecisionCategories.Count == 0 || - request.Decisions.Any(decision => query.DecisionCategories.Contains(decision.Type.Category)); - - private static bool MatchesMetadata( - IReadOnlyDictionary requestMetadata, - IReadOnlyDictionary queryMetadata) - { - foreach (var pair in queryMetadata) - { - if (!requestMetadata.TryGetValue(pair.Key, out var value)) - return false; - - if (!Equals(value, pair.Value)) - return false; - } - - return true; - } -} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestActorFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestActorFilter.cs new file mode 100644 index 0000000..af2981d --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestActorFilter.cs @@ -0,0 +1,17 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Groups actor oriented request query filters. +/// +public sealed record MutationRequestActorFilter +{ + /// + /// Actor identifiers to include. + /// + public IReadOnlySet ActorIds { get; init; } = new HashSet(); + + /// + /// Actor names to include. + /// + public IReadOnlySet ActorNames { get; init; } = new HashSet(); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestIntentFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestIntentFilter.cs new file mode 100644 index 0000000..f989ffd --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestIntentFilter.cs @@ -0,0 +1,39 @@ +using ModularityKit.Mutator.Abstractions.Intent; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Groups intent oriented request query filters. +/// +public sealed record MutationRequestIntentFilter +{ + /// + /// Mutation categories to include. + /// + public IReadOnlySet Categories { get; init; } = new HashSet(); + + /// + /// Tags to include from the request intent. + /// + public IReadOnlySet Tags { get; init; } = new HashSet(); + + /// + /// Tag matching strategy. + /// + public MutationRequestTagMatchMode TagMatchMode { get; init; } = MutationRequestTagMatchMode.Any; + + /// + /// Exact metadata key/value pairs to match against request intent metadata. + /// + public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); + + /// + /// Minimum estimated blast radius scope to include. + /// + public BlastRadiusScope? MinimumBlastRadiusScope { get; init; } + + /// + /// Maximum estimated blast radius scope to include. + /// + public BlastRadiusScope? MaximumBlastRadiusScope { get; init; } +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestLifecycleFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestLifecycleFilter.cs new file mode 100644 index 0000000..93c29d0 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestLifecycleFilter.cs @@ -0,0 +1,26 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Groups lifecycle oriented request query filters. +/// +public sealed record MutationRequestLifecycleFilter +{ + /// + /// Request statuses to include. + /// + public IReadOnlySet Statuses { get; init; } = new HashSet(); + + /// + /// Pending reasons to include. + /// + public IReadOnlySet PendingReasons { get; init; } = new HashSet(); + + /// + /// Decision categories that must appear in the request history. + /// + public IReadOnlySet DecisionCategories { get; init; } + = new HashSet(); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestMetadataFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestMetadataFilter.cs new file mode 100644 index 0000000..f265a58 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestMetadataFilter.cs @@ -0,0 +1,12 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Groups request level governance metadata filters. +/// +public sealed record MutationRequestMetadataFilter +{ + /// + /// Exact metadata key/value pairs to match against request level governance metadata. + /// + public IReadOnlyDictionary Values { get; init; } = new Dictionary(); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestScopeFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestScopeFilter.cs new file mode 100644 index 0000000..117cc74 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestScopeFilter.cs @@ -0,0 +1,27 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Groups identifier oriented request query filters. +/// +public sealed record MutationRequestScopeFilter +{ + /// + /// Specific request identifiers to include. + /// + public IReadOnlySet RequestIds { get; init; } = new HashSet(); + + /// + /// State identifiers to include. + /// + public IReadOnlySet StateIds { get; init; } = new HashSet(); + + /// + /// State types to include. + /// + public IReadOnlySet StateTypes { get; init; } = new HashSet(); + + /// + /// Mutation types to include. + /// + public IReadOnlySet MutationTypes { get; init; } = new HashSet(); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTagMatchMode.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTagMatchMode.cs new file mode 100644 index 0000000..89a07af --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTagMatchMode.cs @@ -0,0 +1,17 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Controls how tag filters are evaluated. +/// +public enum MutationRequestTagMatchMode +{ + /// + /// Match requests that contain at least one of the requested tags. + /// + Any = 0, + + /// + /// Match requests that contain all requested tags. + /// + All = 1 +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTimeRangeFilter.cs b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTimeRangeFilter.cs new file mode 100644 index 0000000..11c913d --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/Filters/MutationRequestTimeRangeFilter.cs @@ -0,0 +1,27 @@ +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +/// +/// Groups time window request query filters. +/// +public sealed record MutationRequestTimeRangeFilter +{ + /// + /// Inclusive lower bound for request creation time. + /// + public DateTimeOffset? CreatedFrom { get; init; } + + /// + /// Inclusive upper bound for request creation time. + /// + public DateTimeOffset? CreatedTo { get; init; } + + /// + /// Inclusive lower bound for request update time. + /// + public DateTimeOffset? UpdatedFrom { get; init; } + + /// + /// Inclusive upper bound for request update time. + /// + public DateTimeOffset? UpdatedTo { get; init; } +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueries.cs b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueries.cs new file mode 100644 index 0000000..4bdbff7 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueries.cs @@ -0,0 +1,60 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; + +/// +/// Common request query presets for governance workflows. +/// +public static class MutationRequestQueries +{ + /// + /// Creates query that targets pending requests. + /// + public static MutationRequestQuery Pending() + => new() + { + Lifecycle = new MutationRequestLifecycleFilter + { + Statuses = new HashSet { MutationRequestStatus.Pending } + } + }; + + /// + /// Creates query that targets the pending approval queue. + /// + public static MutationRequestQuery PendingApprovalQueue() + => new() + { + Lifecycle = new MutationRequestLifecycleFilter + { + Statuses = new HashSet { MutationRequestStatus.Pending }, + PendingReasons = new HashSet { PendingMutationReason.Approval }, + DecisionCategories = new HashSet + { + MutationRequestDecisionCategory.Approval + } + } + }; + + /// + /// Creates query that targets approval driven requests that recently moved through approval. + /// + public static MutationRequestQuery RecentApprovals() + => new() + { + Lifecycle = new MutationRequestLifecycleFilter + { + Statuses = new HashSet + { + MutationRequestStatus.Approved, + MutationRequestStatus.Executed + }, + DecisionCategories = new HashSet + { + MutationRequestDecisionCategory.Approval + } + } + }; +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs new file mode 100644 index 0000000..bf91f98 --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQuery.cs @@ -0,0 +1,39 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; + +/// +/// Defines storage agnostic filters for governed mutation request queries. +/// +public sealed record MutationRequestQuery +{ + /// + /// Identifier oriented filters such as request, state, and mutation identity. + /// + public MutationRequestScopeFilter Scope { get; init; } = new(); + + /// + /// Actor oriented filters derived from the request context. + /// + public MutationRequestActorFilter Actor { get; init; } = new(); + + /// + /// Intent oriented filters such as category, tags, metadata, and blast radius. + /// + public MutationRequestIntentFilter Intent { get; init; } = new(); + + /// + /// Request-level governance metadata filters. + /// + public MutationRequestMetadataFilter Metadata { get; init; } = new(); + + /// + /// Lifecycle and decision-history filters. + /// + public MutationRequestLifecycleFilter Lifecycle { get; init; } = new(); + + /// + /// Time-window filters for request creation and update activity. + /// + public MutationRequestTimeRangeFilter TimeRange { get; init; } = new(); +} diff --git a/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueryEvaluator.cs new file mode 100644 index 0000000..177f51d --- /dev/null +++ b/src/Governance/Abstractions/Queries/Model/Requests/MutationRequestQueryEvaluator.cs @@ -0,0 +1,177 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests.Filters; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; + +/// +/// Evaluates query criteria against governed mutation requests. +/// +public static class MutationRequestQueryEvaluator +{ + public static bool Matches(MutationRequest request, MutationRequestQuery query) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(query); + + return MatchesScope(request, query.Scope) && + MatchesActor(request, query.Actor) && + MatchesIntent(request, query.Intent) && + MatchesMetadata(request, query.Metadata) && + MatchesLifecycle(request, query.Lifecycle) && + MatchesTimeRange(request, query.TimeRange); + } + + public static bool HasApprovalActivity(MutationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + return request.ApprovalRequirements.Count > 0 || + request.Decisions.Any(decision => decision.Type.Category == MutationRequestDecisionCategory.Approval); + } + + public static DateTimeOffset GetRecentApprovalTimestamp(MutationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var approvalDecision = request.Decisions + .Where(decision => decision.Type.Category == MutationRequestDecisionCategory.Approval && + decision.Type.Code != MutationRequestApprovalDecisionType.Requested.ToString()) + .OrderByDescending(decision => decision.Timestamp) + .FirstOrDefault(); + + return approvalDecision?.Timestamp ?? request.UpdatedAt; + } + + private static bool MatchesScope(MutationRequest request, MutationRequestScopeFilter filter) + => MatchesRequestId(request, filter) && + MatchesStateId(request, filter) && + MatchesStateType(request, filter) && + MatchesMutationType(request, filter); + + private static bool MatchesActor(MutationRequest request, MutationRequestActorFilter filter) + => MatchesActorId(request, filter) && + MatchesActorName(request, filter); + + private static bool MatchesIntent(MutationRequest request, MutationRequestIntentFilter filter) + => MatchesCategory(request, filter) && + MatchesTags(request, filter) && + MatchesIntentMetadata(request, filter) && + MatchesBlastRadius(request, filter); + + private static bool MatchesMetadata(MutationRequest request, MutationRequestMetadataFilter filter) + => filter.Values.Count == 0 || MatchesMetadata(request.Metadata, filter.Values); + + private static bool MatchesLifecycle(MutationRequest request, MutationRequestLifecycleFilter filter) + => MatchesStatus(request, filter) && + MatchesPendingReason(request, filter) && + MatchesDecisionCategories(request, filter); + + private static bool MatchesTimeRange(MutationRequest request, MutationRequestTimeRangeFilter filter) + => MatchesCreatedAt(request, filter) && + MatchesUpdatedAt(request, filter); + + private static bool MatchesIntentMetadata(MutationRequest request, MutationRequestIntentFilter filter) + => filter.Metadata.Count == 0 || MatchesMetadata(request.Intent.Metadata, filter.Metadata); + + private static bool MatchesRequestId(MutationRequest request, MutationRequestScopeFilter filter) + => filter.RequestIds.Count == 0 || filter.RequestIds.Contains(request.RequestId); + + private static bool MatchesStateId(MutationRequest request, MutationRequestScopeFilter filter) + => filter.StateIds.Count == 0 || filter.StateIds.Contains(request.StateId); + + private static bool MatchesStateType(MutationRequest request, MutationRequestScopeFilter filter) + => filter.StateTypes.Count == 0 || filter.StateTypes.Contains(request.StateType); + + private static bool MatchesMutationType(MutationRequest request, MutationRequestScopeFilter filter) + => filter.MutationTypes.Count == 0 || filter.MutationTypes.Contains(request.MutationType); + + private static bool MatchesActorId(MutationRequest request, MutationRequestActorFilter filter) + => filter.ActorIds.Count == 0 || + (request.Context.ActorId is not null && filter.ActorIds.Contains(request.Context.ActorId)); + + private static bool MatchesActorName(MutationRequest request, MutationRequestActorFilter filter) + => filter.ActorNames.Count == 0 || + (request.Context.ActorName is not null && filter.ActorNames.Contains(request.Context.ActorName)); + + private static bool MatchesCategory(MutationRequest request, MutationRequestIntentFilter filter) + => filter.Categories.Count == 0 || filter.Categories.Contains(request.Intent.Category); + + private static bool MatchesStatus(MutationRequest request, MutationRequestLifecycleFilter filter) + => filter.Statuses.Count == 0 || filter.Statuses.Contains(request.Status); + + private static bool MatchesPendingReason(MutationRequest request, MutationRequestLifecycleFilter filter) + => filter.PendingReasons.Count == 0 || + (request.PendingReason is not null && filter.PendingReasons.Contains(request.PendingReason.Value)); + + private static bool MatchesTags(MutationRequest request, MutationRequestIntentFilter filter) + { + if (filter.Tags.Count == 0) + return true; + + var requestTags = request.Intent.Tags; + return filter.TagMatchMode == MutationRequestTagMatchMode.All + ? filter.Tags.All(requestTags.Contains) + : filter.Tags.Any(requestTags.Contains); + } + + private static bool MatchesBlastRadius(MutationRequest request, MutationRequestIntentFilter filter) + { + if (!filter.MinimumBlastRadiusScope.HasValue && !filter.MaximumBlastRadiusScope.HasValue) + return true; + + var scope = request.Intent.EstimatedBlastRadius?.Scope; + if (scope is null) + return false; + + if (filter.MinimumBlastRadiusScope.HasValue && scope.Value < filter.MinimumBlastRadiusScope.Value) + return false; + + if (filter.MaximumBlastRadiusScope.HasValue && scope.Value > filter.MaximumBlastRadiusScope.Value) + return false; + + return true; + } + + private static bool MatchesCreatedAt(MutationRequest request, MutationRequestTimeRangeFilter filter) + { + if (filter.CreatedFrom.HasValue && request.CreatedAt < filter.CreatedFrom.Value) + return false; + + if (filter.CreatedTo.HasValue && request.CreatedAt > filter.CreatedTo.Value) + return false; + + return true; + } + + private static bool MatchesUpdatedAt(MutationRequest request, MutationRequestTimeRangeFilter filter) + { + if (filter.UpdatedFrom.HasValue && request.UpdatedAt < filter.UpdatedFrom.Value) + return false; + + if (filter.UpdatedTo.HasValue && request.UpdatedAt > filter.UpdatedTo.Value) + return false; + + return true; + } + + private static bool MatchesDecisionCategories(MutationRequest request, MutationRequestLifecycleFilter filter) + => filter.DecisionCategories.Count == 0 || + request.Decisions.Any(decision => filter.DecisionCategories.Contains(decision.Type.Category)); + + private static bool MatchesMetadata( + IReadOnlyDictionary requestMetadata, + IReadOnlyDictionary queryMetadata) + { + foreach (var pair in queryMetadata) + { + if (!requestMetadata.TryGetValue(pair.Key, out var value)) + return false; + + if (!Equals(value, pair.Value)) + return false; + } + + return true; + } +} diff --git a/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs b/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs index 437a436..5a4ccb8 100644 --- a/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs +++ b/src/Governance/Runtime/Execution/Mutation/GovernedMutation.cs @@ -2,19 +2,26 @@ using ModularityKit.Mutator.Abstractions.Engine; using ModularityKit.Mutator.Abstractions.Intent; using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Runtime.Execution.Mutation; /// -/// Wraps a mutation so governance request identifiers flow into core execution audit and history metadata. +/// Wraps mutation so governance request identifiers flow into core execution audit and history metadata. /// internal sealed class GovernedMutation : IMutation { + private const string GovernanceRequestIdMetadataKey = "GovernanceRequestId"; + private const string GovernanceRequestMetadataKey = "GovernanceRequestMetadata"; + private const string GovernanceIntentMetadataKey = "GovernanceIntentMetadata"; + private const string GovernanceEstimatedBlastRadiusMetadataKey = "GovernanceEstimatedBlastRadius"; + private readonly IMutation _inner; - public GovernedMutation(IMutation inner, string requestId, string stateId) + public GovernedMutation(IMutation inner, MutationRequest request) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + ArgumentNullException.ThrowIfNull(request); Intent = new MutationIntent { @@ -23,17 +30,17 @@ public GovernedMutation(IMutation inner, string requestId, string stateI Description = _inner.Intent.Description, RiskLevel = _inner.Intent.RiskLevel, IsReversible = _inner.Intent.IsReversible, - EstimatedBlastRadius = _inner.Intent.EstimatedBlastRadius, - Tags = _inner.Intent.Tags, + EstimatedBlastRadius = request.Intent.EstimatedBlastRadius ?? _inner.Intent.EstimatedBlastRadius, + Tags = MergeTags(request.Intent.Tags, _inner.Intent.Tags), CreatedAt = _inner.Intent.CreatedAt, - Metadata = MergeMetadata(_inner.Intent.Metadata, requestId) + Metadata = MergeIntentMetadata(request) }; 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) + StateId = string.IsNullOrWhiteSpace(_inner.Context.StateId) ? request.StateId : _inner.Context.StateId, + CorrelationId = string.IsNullOrWhiteSpace(_inner.Context.CorrelationId) ? request.RequestId : _inner.Context.CorrelationId, + Metadata = MergeContextMetadata(request) }; } @@ -47,15 +54,40 @@ public GovernedMutation(IMutation inner, string requestId, string stateI public MutationResult Simulate(TState state) => _inner.Simulate(state); - private static IReadOnlyDictionary MergeMetadata( - IReadOnlyDictionary source, - string requestId) + private IReadOnlyDictionary MergeContextMetadata(MutationRequest request) { - var metadata = new Dictionary(source) + var metadata = new Dictionary(_inner.Context.Metadata) { - ["GovernanceRequestId"] = requestId + [GovernanceRequestIdMetadataKey] = request.RequestId }; + if (request.Metadata.Count > 0) + metadata[GovernanceRequestMetadataKey] = request.Metadata; + + if (request.Intent.Metadata.Count > 0) + metadata[GovernanceIntentMetadataKey] = request.Intent.Metadata; + + if (request.Intent.EstimatedBlastRadius is not null) + metadata[GovernanceEstimatedBlastRadiusMetadataKey] = request.Intent.EstimatedBlastRadius; + return metadata; } + + private IReadOnlyDictionary MergeIntentMetadata(MutationRequest request) + { + var metadata = new Dictionary(request.Intent.Metadata) + { + [GovernanceRequestIdMetadataKey] = request.RequestId + }; + + if (_inner.Intent.Metadata.Count > 0) + metadata["ExecutionIntentMetadata"] = _inner.Intent.Metadata; + + return metadata; + } + + private static IReadOnlySet MergeTags( + IReadOnlySet requestTags, + IReadOnlySet executionTags) + => new HashSet(requestTags.Concat(executionTags), StringComparer.Ordinal); } diff --git a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs index 2b16e54..3896187 100644 --- a/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs +++ b/src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs @@ -46,39 +46,35 @@ public async Task> ExecuteApproved( ArgumentNullException.ThrowIfNull(resultingStateVersionProvider); ArgumentNullException.ThrowIfNull(governanceContext); - var resolution = await _resolutionManager.ResolveAndStore( + var execution = await ResolveExecutionContext( requestId, + mutation, + currentState, currentStateVersion, + resultingStateVersionProvider, governanceContext, strategy, cancellationToken).ConfigureAwait(false); - if (resolution.Outcome is MutationRequestVersionResolutionOutcome.RejectedAsStale or + if (execution.Resolution.Outcome is MutationRequestVersionResolutionOutcome.RejectedAsStale or MutationRequestVersionResolutionOutcome.RequiresRenewedApproval) { - return _outcomeHandler.BuildNonExecutedResult(resolution); + return _outcomeHandler.BuildNonExecutedResult(execution.Resolution); } - var governedMutation = new GovernedMutation(mutation, requestId, resolution.Request.StateId); MutationResult mutationResult; try { - mutationResult = await _mutationEngine - .ExecuteAsync(governedMutation, currentState, cancellationToken) - .ConfigureAwait(false); + mutationResult = await ExecuteMutation(execution, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { await _outcomeHandler.PersistRejectedExecution( - resolution.Request, - governanceContext, + execution.Resolution.Request, + execution.GovernanceContext, $"Governed execution threw '{ex.GetType().Name}': {ex.Message}", - new Dictionary - { - ["CurrentStateVersion"] = currentStateVersion, - ["ExecutionFailureType"] = ex.GetType().Name - }, + GovernedExecutionFailureMetadataFactory.CreateExceptionMetadata(execution.CurrentStateVersion, ex), cancellationToken).ConfigureAwait(false); throw; @@ -87,35 +83,32 @@ await _outcomeHandler.PersistRejectedExecution( if (!mutationResult.IsSuccess || mutationResult.NewState is null) { var rejectedRequest = await _outcomeHandler.PersistRejectedExecution( - resolution.Request, - governanceContext, + execution.Resolution.Request, + execution.GovernanceContext, GovernedExecutionDecisionFactory.BuildRejectedExecutionReason(mutationResult), - new Dictionary - { - ["CurrentStateVersion"] = currentStateVersion, - ["HasPolicyDecisions"] = mutationResult.PolicyDecisions.Count > 0, - ["HasValidationErrors"] = !mutationResult.ValidationResult.IsValid - }, + GovernedExecutionFailureMetadataFactory.CreateRejectedExecutionMetadata( + execution.CurrentStateVersion, + mutationResult), cancellationToken).ConfigureAwait(false); return _outcomeHandler.BuildNonExecutedResult( - resolution with { Request = rejectedRequest }, + execution.Resolution with { Request = rejectedRequest }, mutationResult); } - var resultingStateVersion = resultingStateVersionProvider(mutationResult.NewState); + var resultingStateVersion = execution.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, + execution.Resolution.Request, resultingStateVersion, - governanceContext, + execution.GovernanceContext, mutationResult, cancellationToken).ConfigureAwait(false); return _outcomeHandler.BuildExecutedResult( - resolution, + execution.Resolution, mutationResult, executedRequest, resultingStateVersion); @@ -138,4 +131,38 @@ public Task> ExecuteApproved( governanceContext, strategy, cancellationToken); + + private async Task> ResolveExecutionContext( + string requestId, + IMutation mutation, + TState currentState, + string currentStateVersion, + Func resultingStateVersionProvider, + MutationContext governanceContext, + VersionedRequestResolutionStrategy strategy, + CancellationToken cancellationToken) + { + var resolution = await _resolutionManager.ResolveAndStore( + requestId, + currentStateVersion, + governanceContext, + strategy, + cancellationToken).ConfigureAwait(false); + + return new GovernedExecutionContext( + resolution, + new GovernedMutation(mutation, resolution.Request), + currentState, + currentStateVersion, + resultingStateVersionProvider, + governanceContext); + } + + private Task> ExecuteMutation( + GovernedExecutionContext execution, + CancellationToken cancellationToken) + => _mutationEngine.ExecuteAsync( + execution.Mutation, + execution.CurrentState, + cancellationToken); } diff --git a/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionContext.cs b/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionContext.cs new file mode 100644 index 0000000..4ea7567 --- /dev/null +++ b/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionContext.cs @@ -0,0 +1,16 @@ +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Governance.Abstractions.Resolution.Model; +using ModularityKit.Mutator.Governance.Runtime.Execution.Mutation; + +namespace ModularityKit.Mutator.Governance.Runtime.Execution.Orchestration; + +/// +/// Carries the resolved runtime inputs for one governed execution attempt. +/// +internal sealed record GovernedExecutionContext( + MutationRequestVersionResolution Resolution, + GovernedMutation Mutation, + TState CurrentState, + string CurrentStateVersion, + Func ResultingStateVersionProvider, + MutationContext GovernanceContext); diff --git a/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionFailureMetadataFactory.cs b/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionFailureMetadataFactory.cs new file mode 100644 index 0000000..4d049da --- /dev/null +++ b/src/Governance/Runtime/Execution/Orchestration/GovernedExecutionFailureMetadataFactory.cs @@ -0,0 +1,28 @@ +using ModularityKit.Mutator.Abstractions.Results; + +namespace ModularityKit.Mutator.Governance.Runtime.Execution.Orchestration; + +/// +/// Builds consistent governance metadata payloads for execution failures. +/// +internal static class GovernedExecutionFailureMetadataFactory +{ + public static IReadOnlyDictionary CreateExceptionMetadata( + string currentStateVersion, + Exception exception) + => new Dictionary + { + ["CurrentStateVersion"] = currentStateVersion, + ["ExecutionFailureType"] = exception.GetType().Name + }; + + public static IReadOnlyDictionary CreateRejectedExecutionMetadata( + string currentStateVersion, + MutationResult mutationResult) + => new Dictionary + { + ["CurrentStateVersion"] = currentStateVersion, + ["HasPolicyDecisions"] = mutationResult.PolicyDecisions.Count > 0, + ["HasValidationErrors"] = !mutationResult.ValidationResult.IsValid + }; +} diff --git a/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs index f513697..24352a0 100644 --- a/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs +++ b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs @@ -1,6 +1,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Exceptions.Storage; using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; @@ -184,7 +186,7 @@ public Task> GetRecentApprovalsAsync( int? take = null, CancellationToken cancellationToken = default) { - var effectiveQuery = query ?? MutationRequestQuery.RecentApprovals(); + var effectiveQuery = query ?? MutationRequestQueries.RecentApprovals(); lock (_lock) { diff --git a/src/Redis/Storage/Candidates/Planning/RedisMutationRequestCandidatePlanBuilder.cs b/src/Redis/Storage/Candidates/Planning/RedisMutationRequestCandidatePlanBuilder.cs index dd08afd..dc57095 100644 --- a/src/Redis/Storage/Candidates/Planning/RedisMutationRequestCandidatePlanBuilder.cs +++ b/src/Redis/Storage/Candidates/Planning/RedisMutationRequestCandidatePlanBuilder.cs @@ -1,5 +1,5 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Redis.Keys; using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Models; using StackExchange.Redis; @@ -7,21 +7,21 @@ namespace ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Planning; /// -/// Builds candidate-id lookup plans for Redis-backed request queries. +/// Builds candidate id lookup plans for Redis backed request queries. /// internal sealed class RedisMutationRequestCandidatePlanBuilder(RedisMutationRequestKeyspace keyspace) { private readonly RedisMutationRequestKeyspace _keyspace = keyspace ?? throw new ArgumentNullException(nameof(keyspace)); /// - /// Builds a plan that loads all known request identifiers. + /// Builds plan that loads all known request identifiers. /// /// The candidate plan. public RedisMutationRequestCandidatePlan BuildAllRequestsPlan() => Single(_keyspace.RequestIds()); /// - /// Builds a plan that loads request identifiers for a specific state. + /// Builds plan that loads request identifiers for specific state. /// /// The state identifier. /// The candidate plan. @@ -29,7 +29,7 @@ public RedisMutationRequestCandidatePlan BuildByStateIdPlan(string stateId) => Single(_keyspace.RequestsByStateId(stateId)); /// - /// Builds a plan that loads pending request identifiers, optionally narrowed by pending reason. + /// Builds plan that loads pending request identifiers, optionally narrowed by pending reason. /// /// The optional pending reason. /// The candidate plan. @@ -37,7 +37,7 @@ public RedisMutationRequestCandidatePlan BuildPendingPlan(PendingMutationReason? => Single(GetPendingKey(reason)); /// - /// Builds a plan that loads pending request identifiers for a specific state. + /// Builds plan that loads pending request identifiers for specific state. /// /// The state identifier. /// The optional pending reason. @@ -46,7 +46,7 @@ public RedisMutationRequestCandidatePlan BuildPendingByStateIdPlan(string stateI => Intersect(_keyspace.RequestsByStateId(stateId), GetPendingKey(reason)); /// - /// Builds a best-effort Redis candidate plan for the supplied request query. + /// Builds best effort Redis candidate plan for the supplied request query. /// /// The request query to analyze. /// The candidate plan. @@ -54,22 +54,22 @@ public RedisMutationRequestCandidatePlan BuildQueryPlan(MutationRequestQuery que { ArgumentNullException.ThrowIfNull(query); - if (query.RequestIds.Count > 0) - return Explicit(query.RequestIds); + if (query.Scope.RequestIds.Count > 0) + return Explicit(query.Scope.RequestIds); - if (query.PendingReasons.Count > 0) - return Union(query.PendingReasons.Select(_keyspace.PendingRequestIds)); + if (query.Lifecycle.PendingReasons.Count > 0) + return Union(query.Lifecycle.PendingReasons.Select(_keyspace.PendingRequestIds)); - if (query.Statuses.Count > 0) + if (query.Lifecycle.Statuses.Count > 0) { - var pendingOnly = query.Statuses.All(status => status == MutationRequestStatus.Pending); + var pendingOnly = query.Lifecycle.Statuses.All(status => status == MutationRequestStatus.Pending); return pendingOnly ? BuildPendingPlan(reason: null) - : Union(query.Statuses.Select(_keyspace.RequestsByStatus)); + : Union(query.Lifecycle.Statuses.Select(_keyspace.RequestsByStatus)); } - if (query.StateIds.Count > 0) - return Union(query.StateIds.Select(_keyspace.RequestsByStateId)); + if (query.Scope.StateIds.Count > 0) + return Union(query.Scope.StateIds.Select(_keyspace.RequestsByStateId)); return BuildAllRequestsPlan(); } diff --git a/src/Redis/Storage/Candidates/RedisMutationRequestQueryCandidateSelector.cs b/src/Redis/Storage/Candidates/RedisMutationRequestQueryCandidateSelector.cs index b642e19..78805c0 100644 --- a/src/Redis/Storage/Candidates/RedisMutationRequestQueryCandidateSelector.cs +++ b/src/Redis/Storage/Candidates/RedisMutationRequestQueryCandidateSelector.cs @@ -1,5 +1,5 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Execution; using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Models; using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Planning; @@ -7,7 +7,7 @@ namespace ModularityKit.Mutator.Governance.Redis.Storage.Candidates; /// -/// Selects Redis request-id candidates for higher-level request queries. +/// Selects request id candidates for higher level request queries. /// internal sealed class RedisMutationRequestQueryCandidateSelector( RedisMutationRequestCandidatePlanBuilder planBuilder, @@ -15,7 +15,7 @@ internal sealed class RedisMutationRequestQueryCandidateSelector( { private readonly RedisMutationRequestCandidatePlanBuilder _planBuilder = planBuilder ?? throw new ArgumentNullException(nameof(planBuilder)); - + private readonly RedisMutationRequestCandidateExecutor _candidateExecutor = candidateExecutor ?? throw new ArgumentNullException(nameof(candidateExecutor)); @@ -28,7 +28,7 @@ public Task> LoadAllRequestIdsAsync(CancellationToken canc LoadAsync(_planBuilder.BuildAllRequestsPlan(), cancellationToken); /// - /// Loads request identifiers for a specific state. + /// Loads request identifiers for specific state. /// /// The state identifier. /// The cancellation token. @@ -46,7 +46,7 @@ public Task> LoadPendingAsync(PendingMutationReason? reaso LoadAsync(_planBuilder.BuildPendingPlan(reason), cancellationToken); /// - /// Loads pending request identifiers for a specific state. + /// Loads pending request identifiers for specific state. /// /// The state identifier. /// The optional pending reason. @@ -56,7 +56,7 @@ public Task> LoadPendingByStateIdAsync(string stateId, Pen LoadAsync(_planBuilder.BuildPendingByStateIdPlan(stateId, reason), cancellationToken); /// - /// Loads request identifiers for a general request query using Redis-side candidate narrowing. + /// Loads request identifiers for general request query using side candidate narrowing. /// /// The query to analyze. /// The cancellation token. diff --git a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs index c035202..033076f 100644 --- a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs +++ b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs @@ -1,4 +1,6 @@ -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; @@ -8,12 +10,23 @@ namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization /// internal static class RedisMutationRequestOrdering { + /// + /// Orders request results by creation time and request identifier. + /// + /// Requests to order. + /// Materialized request results in ascending creation order. public static IReadOnlyList ByCreated(IEnumerable requests) => requests .OrderBy(request => request.CreatedAt) .ThenBy(request => request.RequestId) .ToList(); + /// + /// Orders requests by the most recent approval activity and applies an optional result limit. + /// + /// Requests to order. + /// Optional maximum number of results to return. + /// Materialized request results ordered for recent approval views. public static IReadOnlyList ByRecentApprovals( IEnumerable requests, int? take) @@ -29,6 +42,11 @@ public static IReadOnlyList ByRecentApprovals( return results.ToList(); } + /// + /// Orders pending approval projections by request creation and approval step sequence. + /// + /// Approval views to order. + /// Materialized approval views in pending queue order. public static IReadOnlyList ByPendingApprovalView( IEnumerable views) => views @@ -38,6 +56,12 @@ public static IReadOnlyList ByPendingApprovalView( .ThenBy(view => view.Approval.ApprovalId) .ToList(); + /// + /// Orders decision projections by decision recency and applies an optional result limit. + /// + /// Decision views to order. + /// Optional maximum number of results to return. + /// Materialized decision views ordered from newest to oldest. public static IReadOnlyList ByRecentDecisionView( IEnumerable views, int? take) diff --git a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs index 18a128a..b5ff76e 100644 --- a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs +++ b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs @@ -1,5 +1,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; @@ -10,7 +12,7 @@ namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization internal static class RedisMutationRequestQueryMaterializer { /// - /// Applies a general request query to already materialized requests. + /// Applies general request query to already materialized requests. /// /// The materialized requests. /// The query to evaluate. @@ -27,7 +29,7 @@ public static IReadOnlyList ApplyQuery( } /// - /// Applies a pending request query to already materialized requests. + /// Applies pending request query to already materialized requests. /// /// The materialized requests. /// The query to evaluate. @@ -46,7 +48,7 @@ public static IReadOnlyList ApplyPendingQuery( } /// - /// Applies a pending approval queue query to already materialized requests. + /// Applies pending approval queue query to already materialized requests. /// /// The materialized requests. /// The query to evaluate. @@ -66,7 +68,7 @@ public static IReadOnlyList ApplyPendingApprovalQueueQuery( } /// - /// Applies a recent approvals query to already materialized requests. + /// Applies recent approvals query to already materialized requests. /// /// The materialized requests. /// The query to evaluate. @@ -89,7 +91,7 @@ public static IReadOnlyList ApplyRecentApprovalsQuery( } /// - /// Applies a pending approval view query to already materialized requests. + /// Applies pending approval view query to already materialized requests. /// /// The materialized requests. /// The approval query to evaluate. @@ -109,7 +111,7 @@ public static IReadOnlyList ApplyPendingApprovalViewQuery( } /// - /// Applies a recent decision query to already materialized requests. + /// Applies recent decision query to already materialized requests. /// /// The materialized requests. /// The decision query to evaluate. diff --git a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestViewProjector.cs b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestViewProjector.cs index e9b2fd4..4e0e883 100644 --- a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestViewProjector.cs +++ b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestViewProjector.cs @@ -1,4 +1,5 @@ -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; @@ -8,6 +9,11 @@ namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization /// internal static class RedisMutationRequestViewProjector { + /// + /// Projects governed requests into approval-centric query views. + /// + /// Requests to project. + /// Approval views produced from request approval requirements. public static IEnumerable ToApprovalViews(IEnumerable requests) { ArgumentNullException.ThrowIfNull(requests); @@ -19,6 +25,11 @@ public static IEnumerable ToApprovalViews(IEnumerable + /// Projects governed requests into decision-centric query views. + /// + /// Requests to project. + /// Decision views produced from request history entries. public static IEnumerable ToDecisionViews(IEnumerable requests) { ArgumentNullException.ThrowIfNull(requests); diff --git a/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryDocumentLoader.cs b/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryDocumentLoader.cs index 46b3a5b..8108e23 100644 --- a/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryDocumentLoader.cs +++ b/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryDocumentLoader.cs @@ -1,5 +1,5 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Redis.Storage.Candidates; using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Reading; diff --git a/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryReader.cs b/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryReader.cs index a786a84..346cdb0 100644 --- a/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryReader.cs +++ b/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryReader.cs @@ -1,5 +1,7 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; @@ -120,7 +122,7 @@ public async Task> GetRecentApprovalsAsync( int? take = null, CancellationToken cancellationToken = default) { - var effectiveQuery = query ?? MutationRequestQuery.RecentApprovals(); + var effectiveQuery = query ?? MutationRequestQueries.RecentApprovals(); var requests = await _documentLoader.LoadByRequestQueryAsync(effectiveQuery, cancellationToken).ConfigureAwait(false); return RedisMutationRequestQueryMaterializer.ApplyRecentApprovalsQuery(requests, effectiveQuery, take); } diff --git a/src/Redis/Storage/RedisMutationRequestStore.cs b/src/Redis/Storage/RedisMutationRequestStore.cs index 7d851f8..8d2a15a 100644 --- a/src/Redis/Storage/RedisMutationRequestStore.cs +++ b/src/Redis/Storage/RedisMutationRequestStore.cs @@ -1,6 +1,8 @@ using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts; -using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Approvals; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Decisions; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model.Requests; using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; using ModularityKit.Mutator.Governance.Abstractions.Storage; using ModularityKit.Mutator.Governance.Redis.Storage.Persistence; @@ -10,7 +12,7 @@ namespace ModularityKit.Mutator.Governance.Redis.Storage; /// -/// Redis-backed implementation of governed mutation request storage and query access. +/// Implementation of governed mutation request storage and query access. /// public sealed class RedisMutationRequestStore : IMutationRequestStore, IMutationRequestQueryStore { @@ -26,7 +28,7 @@ internal RedisMutationRequestStore( } /// - /// Creates a governed mutation request in Redis storage. + /// Creates governed mutation request in storage. /// /// The request to create. /// The cancellation token. @@ -37,7 +39,7 @@ public Task Create( => _persistence.Create(request, cancellationToken); /// - /// Attempts to store a governed mutation request update using optimistic concurrency. + /// Attempts to store governed mutation request update using optimistic concurrency. /// /// The request to store. /// The expected current revision. @@ -50,7 +52,7 @@ public Task Create( => _persistence.TryStore(request, expectedRevision, cancellationToken); /// - /// Reads a governed mutation request by identifier. + /// Reads governed mutation request by identifier. /// /// The request identifier. /// The cancellation token. @@ -61,7 +63,7 @@ public Task Create( => _persistence.Get(requestId, cancellationToken); /// - /// Reads governed mutation requests for a specific state identifier. + /// Reads governed mutation requests for specific state identifier. /// /// The state identifier. /// The cancellation token. @@ -83,7 +85,7 @@ public Task> GetPending( => _queryReader.GetPending(reason, cancellationToken); /// - /// Reads pending governed mutation requests for a specific state identifier. + /// Reads pending governed mutation requests for specific state identifier. /// /// The state identifier. /// The optional pending reason.