Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions Docs/Decision/Adr/ADR_033_Governance_Query_Model_Decomposition.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions Docs/Decision/listadr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 5 additions & 3 deletions Examples/Governance/Queries/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,7 +78,7 @@ public static void PrintRequests(IReadOnlyList<MutationRequest> requests)
Console.WriteLine("- none");
}

public static void PrintApprovals(IReadOnlyList<ModularityKit.Mutator.Governance.Abstractions.Queries.Model.MutationApprovalView> approvals)
public static void PrintApprovals(IReadOnlyList<MutationApprovalView> approvals)
{
foreach (var approval in approvals)
{
Expand All @@ -88,7 +90,7 @@ public static void PrintApprovals(IReadOnlyList<ModularityKit.Mutator.Governance
Console.WriteLine("- none");
}

public static void PrintDecisions(IReadOnlyList<ModularityKit.Mutator.Governance.Abstractions.Queries.Model.MutationRequestDecisionView> decisions)
public static void PrintDecisions(IReadOnlyList<MutationRequestDecisionView> decisions)
{
foreach (var decision in decisions)
{
Expand All @@ -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<string> { category.ToLowerInvariant(), "approval" },
EstimatedBlastRadius = category == "Security" ? BlastRadius.Module : BlastRadius.Single,
Metadata = new Dictionary<string, object>
{
["risk-owner"] = category == "Security" ? "platform" : "finance"
}
},
context: MutationContext.User("requester", "Requester", "Need governed change"),
requirements:
Expand All @@ -126,6 +134,10 @@ private static MutationRequest CreatePendingApprovalRequest(
RequestId = requestId,
CreatedAt = createdAt,
UpdatedAt = createdAt,
Metadata = new Dictionary<string, object>
{
["ticket"] = category == "Security" ? "INC-42" : "BILL-7"
},
ApprovalRequirements =
[
new MutationApprovalRequirement
Expand All @@ -150,15 +162,25 @@ private static MutationRequest CreateExternalCheckRequest(
{
OperationName = "ExampleOperation",
Category = category,
Description = "Waiting for dependency validation"
Description = "Waiting for dependency validation",
Tags = new HashSet<string> { "external-check" },
EstimatedBlastRadius = BlastRadius.Single,
Metadata = new Dictionary<string, object>
{
["risk-owner"] = "release"
}
},
context: MutationContext.Service("release-orchestrator", "Waiting for external dependency"),
pendingReason: PendingMutationReason.ExternalCheck)
with
{
RequestId = requestId,
CreatedAt = createdAt,
UpdatedAt = createdAt
UpdatedAt = createdAt,
Metadata = new Dictionary<string, object>
{
["ticket"] = "REL-99"
}
};

private static MutationRequest CreateRecentlyApprovedRequest(
Expand All @@ -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<string> { category.ToLowerInvariant(), "approved" },
EstimatedBlastRadius = category == "Security" ? BlastRadius.Module : BlastRadius.Single,
Metadata = new Dictionary<string, object>
{
["risk-owner"] = category == "Security" ? "platform" : "finance"
}
},
context: MutationContext.User("requester", "Requester", "Need privileged change"),
requirements:
Expand All @@ -189,6 +217,10 @@ private static MutationRequest CreateRecentlyApprovedRequest(
PendingReason = null,
CreatedAt = approvedAt.AddMinutes(-20),
UpdatedAt = approvedAt,
Metadata = new Dictionary<string, object>
{
["ticket"] = category == "Security" ? "INC-77" : "BILL-9"
},
ApprovalRequirements =
[
new MutationApprovalRequirement
Expand Down Expand Up @@ -252,7 +284,13 @@ private static MutationRequest CreateResolvedRequest(
{
OperationName = "ExampleOperation",
Category = category,
Description = "Resolved governed request"
Description = "Resolved governed request",
Tags = new HashSet<string> { category.ToLowerInvariant(), "resolution" },
EstimatedBlastRadius = BlastRadius.Single,
Metadata = new Dictionary<string, object>
{
["risk-owner"] = "governance"
}
},
context: MutationContext.Service("governance-runtime", "Resolve stale request"))
with
Expand All @@ -261,6 +299,10 @@ private static MutationRequest CreateResolvedRequest(
Status = MutationRequestStatus.Approved,
CreatedAt = decisionTimestamp.AddMinutes(-30),
UpdatedAt = decisionTimestamp,
Metadata = new Dictionary<string, object>
{
["ticket"] = "CFG-5"
},
Decisions =
[
MutationRequestDecision.Lifecycle(
Expand Down Expand Up @@ -293,7 +335,13 @@ private static MutationRequest CreateExecutedRequest(
{
OperationName = "ExampleOperation",
Category = category,
Description = "Executed governed request"
Description = "Executed governed request",
Tags = new HashSet<string> { category.ToLowerInvariant(), "executed" },
EstimatedBlastRadius = BlastRadius.Single,
Metadata = new Dictionary<string, object>
{
["risk-owner"] = "finance"
}
},
context: MutationContext.Service("governance-runtime", "Execute approved request"))
with
Expand All @@ -303,6 +351,10 @@ private static MutationRequest CreateExecutedRequest(
CreatedAt = executedAt.AddMinutes(-15),
UpdatedAt = executedAt,
ExecutedAt = executedAt,
Metadata = new Dictionary<string, object>
{
["ticket"] = "BILL-22"
},
Decisions =
[
MutationRequestDecision.Lifecycle(
Expand Down
34 changes: 30 additions & 4 deletions Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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> { PendingMutationReason.ExternalCheck }
Lifecycle = new MutationRequestLifecycleFilter
{
PendingReasons = new HashSet<PendingMutationReason> { PendingMutationReason.ExternalCheck }
}
}));

GovernanceQueriesSampleData.PrintSection("Billing Requests");
GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery
{
Categories = new HashSet<string> { "Billing" }
Intent = new MutationRequestIntentFilter
{
Categories = new HashSet<string> { "Billing" }
}
}));

GovernanceQueriesSampleData.PrintSection("Requests For tenant-42:roles");
GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery
{
StateIds = new HashSet<string> { "tenant-42:roles" }
Scope = new MutationRequestScopeFilter
{
StateIds = new HashSet<string> { "tenant-42:roles" }
}
}));

GovernanceQueriesSampleData.PrintSection("Metadata-classified Security Requests");
GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery
{
Intent = new MutationRequestIntentFilter
{
Tags = new HashSet<string> { "security" },
Metadata = new Dictionary<string, object?> { ["risk-owner"] = "platform" },
MinimumBlastRadiusScope = BlastRadiusScope.Module
},
Metadata = new MutationRequestMetadataFilter
{
Values = new Dictionary<string, object?> { ["ticket"] = "INC-42" }
}
}));

GovernanceQueriesSampleData.PrintSection("Recent Approval Driven Requests");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading