diff --git a/.github/workflows/publish-artifacts.yml b/.github/workflows/publish-artifacts.yml
index e065820..44cd8d5 100644
--- a/.github/workflows/publish-artifacts.yml
+++ b/.github/workflows/publish-artifacts.yml
@@ -7,6 +7,18 @@ on:
description: "Optional package version, usually the release tag without the leading v."
required: false
type: string
+ core_version:
+ description: "Optional manual version for ModularityKit.Mutator."
+ required: false
+ type: string
+ governance_version:
+ description: "Optional manual version for ModularityKit.Mutator.Governance."
+ required: false
+ type: string
+ redis_version:
+ description: "Optional manual version for ModularityKit.Mutator.Governance.Redis."
+ required: false
+ type: string
permissions:
contents: read
@@ -28,21 +40,45 @@ jobs:
- name: Restore
run: dotnet restore ModularityKit.Mutator.slnx
- - name: Resolve package version
+ - name: Resolve package versions
id: version
env:
PACKAGE_VERSION: ${{ inputs.package_version }}
+ CORE_VERSION: ${{ inputs.core_version }}
+ GOVERNANCE_VERSION: ${{ inputs.governance_version }}
+ REDIS_VERSION: ${{ inputs.redis_version }}
REF_NAME: ${{ github.ref_name }}
run: |
- version="$PACKAGE_VERSION"
- if [ -z "$version" ]; then
- version="$REF_NAME"
- fi
- version="${version#v}"
- if ! printf '%s' "$version" | grep -Eq '^[0-9]+(\.[0-9]+){1,2}([-+][0-9A-Za-z.-]+)?$'; then
- version="0.1.0"
- fi
- echo "package_version=$version" >> "$GITHUB_OUTPUT"
+ resolve_version() {
+ local value="$1"
+ local fallback="$2"
+
+ if [ -z "$value" ]; then
+ value="$fallback"
+ fi
+
+ if [ -z "$value" ]; then
+ value="$REF_NAME"
+ fi
+
+ value="${value#v}"
+
+ if ! printf '%s' "$value" | grep -Eq '^[0-9]+(\.[0-9]+){1,2}([-+][0-9A-Za-z.-]+)?$'; then
+ value="0.1.0"
+ fi
+
+ printf '%s' "$value"
+ }
+
+ shared_version="$(resolve_version "$PACKAGE_VERSION" "")"
+ core_version="$(resolve_version "$CORE_VERSION" "$shared_version")"
+ governance_version="$(resolve_version "$GOVERNANCE_VERSION" "$shared_version")"
+ redis_version="$(resolve_version "$REDIS_VERSION" "$shared_version")"
+
+ echo "package_version=$shared_version" >> "$GITHUB_OUTPUT"
+ echo "core_version=$core_version" >> "$GITHUB_OUTPUT"
+ echo "governance_version=$governance_version" >> "$GITHUB_OUTPUT"
+ echo "redis_version=$redis_version" >> "$GITHUB_OUTPUT"
- name: Pack core package
run: >
@@ -50,7 +86,7 @@ jobs:
-c Release
--no-restore
-o nupkg
- -p:PackageVersion=${{ steps.version.outputs.package_version }}
+ -p:PackageVersion=${{ steps.version.outputs.core_version }}
- name: Pack governance package
run: >
@@ -58,7 +94,15 @@ jobs:
-c Release
--no-restore
-o nupkg
- -p:PackageVersion=${{ steps.version.outputs.package_version }}
+ -p:PackageVersion=${{ steps.version.outputs.governance_version }}
+
+ - name: Pack Redis governance package
+ run: >
+ dotnet pack src/Redis/ModularityKit.Mutator.Governance.Redis.csproj
+ -c Release
+ --no-restore
+ -o nupkg
+ -p:PackageVersion=${{ steps.version.outputs.redis_version }}
- name: Upload packages
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml
index a898c6c..6bee2a8 100644
--- a/.github/workflows/publish-packages.yml
+++ b/.github/workflows/publish-packages.yml
@@ -7,6 +7,18 @@ on:
description: Release version without the leading "v"
required: false
type: string
+ core_version:
+ description: Optional manual version for ModularityKit.Mutator
+ required: false
+ type: string
+ governance_version:
+ description: Optional manual version for ModularityKit.Mutator.Governance
+ required: false
+ type: string
+ redis_version:
+ description: Optional manual version for ModularityKit.Mutator.Governance.Redis
+ required: false
+ type: string
publish_nuget:
description: Publish to NuGet.org
required: false
@@ -23,6 +35,18 @@ on:
description: Release version without the leading "v"
required: false
type: string
+ core_version:
+ description: Optional manual version for ModularityKit.Mutator
+ required: false
+ type: string
+ governance_version:
+ description: Optional manual version for ModularityKit.Mutator.Governance
+ required: false
+ type: string
+ redis_version:
+ description: Optional manual version for ModularityKit.Mutator.Governance.Redis
+ required: false
+ type: string
publish_nuget:
description: Publish to NuGet.org
required: false
@@ -41,6 +65,9 @@ jobs:
uses: ./.github/workflows/publish-artifacts.yml
with:
package_version: ${{ inputs.version }}
+ core_version: ${{ inputs.core_version }}
+ governance_version: ${{ inputs.governance_version }}
+ redis_version: ${{ inputs.redis_version }}
publish-nuget:
name: Publish to NuGet.org
diff --git a/Docs/Decision/Adr/ADR_029_Governance_Redis_Provider_Package.md b/Docs/Decision/Adr/ADR_029_Governance_Redis_Provider_Package.md
new file mode 100644
index 0000000..c5926d1
--- /dev/null
+++ b/Docs/Decision/Adr/ADR_029_Governance_Redis_Provider_Package.md
@@ -0,0 +1,80 @@
+# ADR-029: Governance Redis Provider Package
+
+## Tag
+#adr_029
+
+## Status
+Accepted
+
+## Date
+2026-06-25
+
+## Scope
+ModularityKit.Mutator.Governance.Redis
+
+## Context
+
+The governance package already defines:
+
+- durable mutation requests
+- optimistic concurrency around request revisions
+- request, approval, and decision query contracts
+- an in-memory implementation suitable for tests and examples
+
+What remained open was a first persistence provider that can move governance state beyond in-memory storage without coupling `ModularityKit.Mutator.Governance` to a database-specific implementation.
+
+The first provider needs to support:
+
+- durable request document storage
+- optimistic concurrency for request updates
+- query-oriented reads over governed request data
+- simple application integration through DI
+
+At the same time, this should remain separate from the governance abstractions package so provider-specific concerns do not leak into the base runtime API.
+
+## Decision
+
+Introduce a dedicated package:
+
+- `ModularityKit.Mutator.Governance.Redis`
+
+The provider should:
+
+- implement `IMutationRequestStore`
+- implement `IMutationRequestQueryStore`
+- register through dedicated DI extensions
+- keep all Redis-specific logic inside the provider package
+- reuse governance query contracts instead of inventing a Redis-specific query surface
+
+The provider should remain additive:
+
+- `ModularityKit.Mutator.Governance` owns contracts and runtime semantics
+- `ModularityKit.Mutator.Governance.Redis` owns Redis persistence and Redis-specific internal read/write mechanics
+
+## Design Rationale
+
+- Governance contracts should stay provider-neutral.
+- Redis is a pragmatic first persistence backend for request-oriented workflows with simple document state and indexable queue views.
+- Package separation allows future providers such as EF Core or PostgreSQL without changing governance abstractions.
+- DI registration gives applications a low-friction way to switch from in-memory storage to Redis-backed storage.
+
+## Consequences
+
+### Positive
+
+- Governance gets a real persistence provider beyond in-memory storage.
+- Redis-backed request and query implementations can evolve independently from governance contracts.
+- Future providers now have a clear packaging precedent.
+- Examples and tests can exercise a provider-backed read side without changing core governance APIs.
+
+### Negative
+
+- Provider package maintenance adds another surface to version and test.
+- Redis-specific internal design decisions must now be documented and kept coherent over time.
+- Query behavior remains contract-compatible but may have different performance characteristics than future relational providers.
+
+## Related ADRs
+
+- ADR-019: Governance Package Separation
+- ADR-022: Governance Request Decisions and Storage
+- ADR-026: Governance Request Query API
diff --git a/Docs/Decision/Adr/ADR_030_Governance_Redis_Request_Storage_and_Query_Strategy.md b/Docs/Decision/Adr/ADR_030_Governance_Redis_Request_Storage_and_Query_Strategy.md
new file mode 100644
index 0000000..a14294a
--- /dev/null
+++ b/Docs/Decision/Adr/ADR_030_Governance_Redis_Request_Storage_and_Query_Strategy.md
@@ -0,0 +1,88 @@
+# ADR-030: Governance Redis Request Storage and Query Strategy
+
+## Tag
+#adr_030
+
+## Status
+Accepted
+
+## Date
+2026-06-25
+
+## Scope
+ModularityKit.Mutator.Governance.Redis
+
+## Context
+
+Once the Redis provider package exists, it still needs a concrete storage and read strategy.
+
+Governed request data has two competing needs:
+
+- writes should stay simple and durable
+- queue-oriented operational reads should avoid a full scan of every request whenever possible
+
+The provider also needs to preserve governance runtime semantics such as:
+
+- optimistic concurrency by request revision
+- storage-agnostic request query filtering
+- approval and decision projections built from parent request state
+
+Without an explicit strategy, the Redis provider could drift into ad hoc key naming, inconsistent indexing, or duplicated query behavior that no longer matches the governance abstractions.
+
+## Decision
+
+The Redis provider stores one serialized request document per request and maintains a small set of Redis secondary indexes for common request-oriented reads.
+
+Storage shape:
+
+- one request JSON document per `MutationRequest`
+- one revision key per request for optimistic concurrency
+- set indexes for:
+ - all request ids
+ - requests by `StateId`
+ - requests by `MutationRequestStatus`
+ - all pending requests
+ - pending requests by `PendingMutationReason`
+
+Query shape:
+
+- Redis index selection happens first through candidate-planning internals
+- matching request documents are then loaded in bulk
+- final filtering is applied through governance query evaluators, not Redis-specific ad hoc logic
+- approval views and decision views are projected from loaded parent requests after candidate selection
+
+Internal provider structure should remain decomposed:
+
+- candidate planning and execution
+- document key creation and payload loading
+- document materialization
+- read-side query orchestration
+
+## Design Rationale
+
+- Document-per-request storage maps naturally to the governance request model.
+- Separate revision keys give a simple optimistic concurrency mechanism in Redis transactions.
+- A small set of explicit secondary indexes improves the common queue and status reads without forcing the provider into a large custom indexing subsystem.
+- Reusing governance evaluators keeps provider behavior aligned with in-memory and future providers.
+- Internal decomposition makes Redis-specific read mechanics easier to evolve without turning one class into the entire provider.
+
+## Consequences
+
+### Positive
+
+- Request writes stay simple and explicit.
+- Common operational views such as pending queues and state/status slices can be narrowed through Redis sets.
+- Query semantics remain aligned with governance abstractions because the final filter pass is evaluator-driven.
+- Internal provider responsibilities are easier to test and evolve independently.
+
+### Negative
+
+- Broad ad hoc queries still fall back to loading candidate request documents and filtering in memory.
+- Index maintenance increases write-path complexity compared to pure document storage.
+- Additional indexes may be needed later for higher-volume provider scenarios.
+
+## Related ADRs
+
+- ADR-022: Governance Request Decisions and Storage
+- ADR-026: Governance Request Query API
+- ADR-029: Governance Redis Provider Package
diff --git a/Docs/Decision/Adr/ADR_031_Governance_Redis_Serialization_and_Document_Compatibility.md b/Docs/Decision/Adr/ADR_031_Governance_Redis_Serialization_and_Document_Compatibility.md
new file mode 100644
index 0000000..84542fb
--- /dev/null
+++ b/Docs/Decision/Adr/ADR_031_Governance_Redis_Serialization_and_Document_Compatibility.md
@@ -0,0 +1,78 @@
+# ADR-031: Governance Redis Serialization and Document Compatibility
+
+## Tag
+#adr_031
+
+## Status
+Accepted
+
+## Date
+2026-06-25
+
+## Scope
+ModularityKit.Mutator.Governance.Redis
+
+## Context
+
+The Redis provider persists governed mutation requests as serialized documents.
+
+That creates a compatibility boundary:
+
+- request data must round-trip without losing governance semantics
+- read models must tolerate heterogeneous metadata values
+- provider internals must avoid a Redis-specific domain model fork
+- future package versions should evolve the serialized shape deliberately, not accidentally
+
+The governance request model already contains nested structures such as:
+
+- mutation intent
+- request metadata
+- approval requirements
+- decision history
+- version resolution state
+
+Without an explicit serialization decision, the provider could drift into:
+
+- inconsistent JSON shapes across components
+- fragile metadata handling for object-valued entries
+- hidden coupling between runtime classes and Redis payload format
+
+## Decision
+
+The Redis provider serializes the existing governance request model directly as JSON documents and treats that JSON shape as the persisted document contract for the provider.
+
+The provider should:
+
+- serialize full `MutationRequest` documents rather than introduce a parallel storage DTO graph
+- keep provider serialization centralized in dedicated Redis serialization components
+- support heterogeneous metadata values through explicit converter handling
+- keep document materialization inside provider internals, not spread across query code paths
+- evolve document shape through deliberate package changes backed by ADRs when compatibility semantics change
+
+## Design Rationale
+
+- Reusing the governance model avoids translation layers that would duplicate request, approval, and decision semantics.
+- Centralized serialization keeps Redis persistence mechanics consistent across reads and writes.
+- Explicit converter support is necessary because governance metadata is intentionally flexible and may contain inferred object values.
+- A single persisted document contract makes provider behavior easier to reason about in tests, examples, and future migrations.
+
+## Consequences
+
+### Positive
+
+- Redis persistence stays aligned with governance runtime semantics.
+- Serialization behavior is easier to test because all provider paths use the same document contract.
+- Metadata and nested governance structures can round-trip without ad hoc per-query parsing.
+- Future compatibility changes now have an explicit decision boundary.
+
+### Negative
+
+- The provider is coupled to the serialized shape of the governance request model.
+- Backward-compatible evolution requires discipline when changing serialized request members.
+- JSON document size grows with request history and approval detail.
+
+## Related ADRs
+
+- ADR-022: Governance Request Decisions and Storage
+- ADR-029: Governance Redis Provider Package
+- ADR-030: Governance Redis Request Storage and Query Strategy
diff --git a/Docs/Decision/Adr/ADR_032_Governance_Redis_Concurrency_and_Index_Maintenance_Model.md b/Docs/Decision/Adr/ADR_032_Governance_Redis_Concurrency_and_Index_Maintenance_Model.md
new file mode 100644
index 0000000..777355b
--- /dev/null
+++ b/Docs/Decision/Adr/ADR_032_Governance_Redis_Concurrency_and_Index_Maintenance_Model.md
@@ -0,0 +1,70 @@
+# ADR-032: Governance Redis Concurrency and Index Maintenance Model
+
+## Tag
+#adr_032
+
+## Status
+Accepted
+
+## Date
+2026-06-25
+
+## Scope
+ModularityKit.Mutator.Governance.Redis
+
+## Context
+
+The Redis provider must preserve governance request semantics during updates, especially:
+
+- optimistic concurrency by request revision
+- consistent request replacement on update
+- index maintenance for queue and lookup reads
+
+Redis does not provide relational constraints or a built-in document update model. That means the provider must explicitly define how request documents, revision values, and secondary indexes are updated together.
+
+Without an explicit model, the provider risks:
+
+- lost updates when two actors write the same request concurrently
+- stale indexes that no longer match the latest request state
+- inconsistent pending queues after status or reason changes
+
+## Decision
+
+The Redis provider uses optimistic concurrency around a per-request revision value and treats document persistence plus secondary-index maintenance as one provider-level update operation.
+
+The provider should:
+
+- require expected revision matching on request updates
+- reject conflicting writes rather than silently overwrite newer request state
+- maintain Redis secondary indexes whenever request status, state, or pending reason changes
+- keep concurrency and index maintenance inside the write path, not in external repair code
+- model indexes as derivations of the canonical request document, not as independent sources of truth
+
+## Design Rationale
+
+- Governance request updates already assume optimistic concurrency semantics, so Redis should preserve that contract.
+- A canonical request document plus derived indexes is simpler than splitting state ownership across many Redis structures.
+- Explicit index maintenance keeps common reads fast enough without changing governance query semantics.
+- Rejecting conflicting writes is safer than last-write-wins for approval and lifecycle state.
+
+## Consequences
+
+### Positive
+
+- Redis updates preserve governance revision semantics.
+- Secondary indexes remain tied to the latest canonical request state.
+- Pending queue views and status-based reads stay operationally useful.
+- Future providers can compare their concurrency model against a documented Redis baseline.
+
+### Negative
+
+- Write paths are more complex than pure document replacement.
+- Bugs in index maintenance can affect operational reads even when the canonical document is correct.
+- Higher write volumes may require further batching or index strategy refinement later.
+
+## Related ADRs
+
+- ADR-021: Governance Pending Mutation Lifecycle
+- ADR-022: Governance Request Decisions and Storage
+- ADR-029: Governance Redis Provider Package
+- ADR-030: Governance Redis Request Storage and Query Strategy
diff --git a/Docs/Decision/listadr.md b/Docs/Decision/listadr.md
index 0eee473..08d6567 100644
--- a/Docs/Decision/listadr.md
+++ b/Docs/Decision/listadr.md
@@ -44,5 +44,9 @@ These ADRs describe the `ModularityKit.Mutator.Governance` extension layer and i
| ADR-026 | Governance Request Query API | [ADR-026](Adr/ADR_026_Governance_Request_Query_API.md) |
| ADR-027 | Governed Execution Manager | [ADR-027](Adr/ADR_027_Governed_Execution_Manager.md) |
| ADR-028 | Governance Approval Workflow Hardening | [ADR-028](Adr/ADR_028_Governance_Approval_Workflow_Hardening.md) |
+| ADR-029 | Governance Redis Provider Package | [ADR-029](Adr/ADR_029_Governance_Redis_Provider_Package.md) |
+| 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) |
> See individual ADRs for detailed context, decision rationale, and consequences.
diff --git a/Examples/Governance/Queries/Program.cs b/Examples/Governance/Queries/Program.cs
new file mode 100644
index 0000000..d503d44
--- /dev/null
+++ b/Examples/Governance/Queries/Program.cs
@@ -0,0 +1,3 @@
+using Queries.Scenarios;
+
+await GovernanceQueriesScenario.Run();
diff --git a/Examples/Governance/Queries/Queries.csproj b/Examples/Governance/Queries/Queries.csproj
new file mode 100644
index 0000000..cd1293b
--- /dev/null
+++ b/Examples/Governance/Queries/Queries.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/Examples/Governance/Queries/README.md b/Examples/Governance/Queries/README.md
new file mode 100644
index 0000000..3b03a16
--- /dev/null
+++ b/Examples/Governance/Queries/README.md
@@ -0,0 +1,49 @@
+# Governance Queries
+
+This example shows the query-oriented read side of `ModularityKit.Mutator.Governance`.
+
+It focuses on listing governed requests, approval work, and decision history without reconstructing those views manually from raw stored records.
+
+## What it demonstrates
+
+- querying governed requests with `MutationRequestQuery`
+- listing the pending approval queue through `IMutationRequestQueryStore`
+- listing pending requests by `PendingMutationReason`
+- querying requests by `StateId` and request category
+- listing recent approval-driven requests
+- projecting pending approval work with `MutationApprovalQuery`
+- projecting recent decision history with `MutationRequestDecisionQuery`
+- projecting recent execution outcomes separately from version resolution history
+- using the in-memory governance store as both write-side storage and query-side read model
+
+## Key files
+
+- [`Program.cs`](Program.cs)
+- [`Scenarios/GovernanceQueriesScenario.cs`](Scenarios/GovernanceQueriesScenario.cs)
+- [`Scenarios/GovernanceQueriesSampleData.cs`](Scenarios/GovernanceQueriesSampleData.cs)
+- [`Scenarios/RequestQueryScenario.cs`](Scenarios/RequestQueryScenario.cs)
+- [`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)
+
+## Run
+
+```bash
+dotnet run --project Examples/Governance/Queries/Queries.csproj
+```
+
+## Expected output
+
+The sample prints:
+
+- pending approval requests
+- pending external-check requests
+- requests filtered by request category
+- requests filtered by state
+- recent approval-driven requests
+- approval views filtered by approver
+- recent version-resolution decisions
+- recent execution outcomes
diff --git a/Examples/Governance/Queries/Scenarios/ApprovalQueryScenario.cs b/Examples/Governance/Queries/Scenarios/ApprovalQueryScenario.cs
new file mode 100644
index 0000000..fea182b
--- /dev/null
+++ b/Examples/Governance/Queries/Scenarios/ApprovalQueryScenario.cs
@@ -0,0 +1,16 @@
+using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts;
+using ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
+
+namespace Queries.Scenarios;
+
+internal static class ApprovalQueryScenario
+{
+ public static async Task Run(IMutationRequestQueryStore queryStore)
+ {
+ GovernanceQueriesSampleData.PrintSection("Pending Approvals For security-lead");
+ GovernanceQueriesSampleData.PrintApprovals(await queryStore.GetPendingApprovalsAsync(new MutationApprovalQuery
+ {
+ ApproverIds = new HashSet { "security-lead" }
+ }));
+ }
+}
diff --git a/Examples/Governance/Queries/Scenarios/DecisionQueryScenario.cs b/Examples/Governance/Queries/Scenarios/DecisionQueryScenario.cs
new file mode 100644
index 0000000..4df2150
--- /dev/null
+++ b/Examples/Governance/Queries/Scenarios/DecisionQueryScenario.cs
@@ -0,0 +1,20 @@
+using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts;
+using ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
+
+namespace Queries.Scenarios;
+
+internal static class DecisionQueryScenario
+{
+ public static async Task Run(IMutationRequestQueryStore queryStore)
+ {
+ GovernanceQueriesSampleData.PrintSection("Recent Version Resolution Decisions");
+ GovernanceQueriesSampleData.PrintDecisions(await queryStore.GetRecentDecisionsAsync(
+ MutationRequestDecisionQuery.RecentVersionResolutions(),
+ take: 5));
+
+ GovernanceQueriesSampleData.PrintSection("Recent Execution Outcomes");
+ GovernanceQueriesSampleData.PrintDecisions(await queryStore.GetRecentDecisionsAsync(
+ MutationRequestDecisionQuery.RecentExecutionOutcomes(),
+ take: 5));
+ }
+}
diff --git a/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs
new file mode 100644
index 0000000..b51f40f
--- /dev/null
+++ b/Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs
@@ -0,0 +1,332 @@
+using ModularityKit.Mutator.Abstractions.Context;
+using ModularityKit.Mutator.Abstractions.Intent;
+using ModularityKit.Mutator.Abstractions.Policies;
+using ModularityKit.Mutator.Governance.Abstractions.Approval.Model;
+using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
+using ModularityKit.Mutator.Governance.Runtime.Storage;
+
+namespace Queries.Scenarios;
+
+internal static class GovernanceQueriesSampleData
+{
+ public static async Task CreateStoreAsync()
+ {
+ var store = new InMemoryMutationRequestStore();
+
+ await store.Create(CreatePendingApprovalRequest(
+ requestId: "req-security-1",
+ stateId: "tenant-42:roles",
+ category: "Security",
+ approverId: "security-lead",
+ createdAt: new DateTimeOffset(2026, 6, 25, 8, 0, 0, TimeSpan.Zero)));
+
+ await store.Create(CreatePendingApprovalRequest(
+ requestId: "req-billing-1",
+ stateId: "tenant-42:quota",
+ category: "Billing",
+ approverId: "billing-owner",
+ createdAt: new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero)));
+
+ await store.Create(CreateResolvedRequest(
+ requestId: "req-resolution-1",
+ stateId: "tenant-42:flags",
+ category: "Configuration",
+ decisionTimestamp: new DateTimeOffset(2026, 6, 25, 10, 30, 0, TimeSpan.Zero)));
+
+ await store.Create(CreateExternalCheckRequest(
+ requestId: "req-check-1",
+ stateId: "tenant-99:release",
+ category: "Configuration",
+ createdAt: new DateTimeOffset(2026, 6, 25, 11, 0, 0, TimeSpan.Zero)));
+
+ await store.Create(CreateRecentlyApprovedRequest(
+ requestId: "req-approved-1",
+ stateId: "tenant-42:roles",
+ category: "Security",
+ approverId: "security-lead",
+ approvedAt: new DateTimeOffset(2026, 6, 25, 11, 30, 0, TimeSpan.Zero)));
+
+ await store.Create(CreateExecutedRequest(
+ requestId: "req-executed-1",
+ stateId: "tenant-42:quota",
+ category: "Billing",
+ executedAt: new DateTimeOffset(2026, 6, 25, 12, 0, 0, TimeSpan.Zero)));
+
+ return store;
+ }
+
+ public static void PrintSection(string title)
+ {
+ Console.WriteLine();
+ Console.WriteLine($"=== {title} ===");
+ }
+
+ public static void PrintRequests(IReadOnlyList requests)
+ {
+ foreach (var request in requests)
+ {
+ Console.WriteLine(
+ $"- {request.RequestId} | {request.StateId} | {request.Intent.Category} | {request.Status} | pending: {request.PendingReason?.ToString() ?? "-"}");
+ }
+
+ if (requests.Count == 0)
+ Console.WriteLine("- none");
+ }
+
+ public static void PrintApprovals(IReadOnlyList approvals)
+ {
+ foreach (var approval in approvals)
+ {
+ Console.WriteLine(
+ $"- {approval.Request.RequestId} | {approval.Request.Intent.Category} | approver: {approval.Approval.ApproverId} | status: {approval.Approval.Status}");
+ }
+
+ if (approvals.Count == 0)
+ Console.WriteLine("- none");
+ }
+
+ public static void PrintDecisions(IReadOnlyList decisions)
+ {
+ foreach (var decision in decisions)
+ {
+ Console.WriteLine(
+ $"- {decision.Request.RequestId} | {decision.Decision.Type.Category}:{decision.Decision.Type.Code} | {decision.Decision.Timestamp:O}");
+ }
+
+ if (decisions.Count == 0)
+ Console.WriteLine("- none");
+ }
+
+ private static MutationRequest CreatePendingApprovalRequest(
+ string requestId,
+ string stateId,
+ string category,
+ string approverId,
+ DateTimeOffset createdAt)
+ => MutationRequestFactory.PendingApproval(
+ stateId: stateId,
+ stateType: "ExampleState",
+ mutationType: "ExampleMutation",
+ intent: new MutationIntent
+ {
+ OperationName = "ExampleOperation",
+ Category = category,
+ Description = $"Governed request for {category.ToLowerInvariant()} flow"
+ },
+ context: MutationContext.User("requester", "Requester", "Need governed change"),
+ requirements:
+ [
+ PolicyRequirement.Approval(approverId, $"Approval required from {approverId}")
+ ])
+ with
+ {
+ RequestId = requestId,
+ CreatedAt = createdAt,
+ UpdatedAt = createdAt,
+ ApprovalRequirements =
+ [
+ new MutationApprovalRequirement
+ {
+ ApproverId = approverId,
+ Status = MutationApprovalRequirementStatus.Pending,
+ StepOrder = 1
+ }
+ ]
+ };
+
+ private static MutationRequest CreateExternalCheckRequest(
+ string requestId,
+ string stateId,
+ string category,
+ DateTimeOffset createdAt)
+ => MutationRequestFactory.Pending(
+ stateId: stateId,
+ stateType: "ExampleState",
+ mutationType: "ExampleMutation",
+ intent: new MutationIntent
+ {
+ OperationName = "ExampleOperation",
+ Category = category,
+ Description = "Waiting for dependency validation"
+ },
+ context: MutationContext.Service("release-orchestrator", "Waiting for external dependency"),
+ pendingReason: PendingMutationReason.ExternalCheck)
+ with
+ {
+ RequestId = requestId,
+ CreatedAt = createdAt,
+ UpdatedAt = createdAt
+ };
+
+ private static MutationRequest CreateRecentlyApprovedRequest(
+ string requestId,
+ string stateId,
+ string category,
+ string approverId,
+ DateTimeOffset approvedAt)
+ => MutationRequestFactory.PendingApproval(
+ stateId: stateId,
+ stateType: "ExampleState",
+ mutationType: "ExampleMutation",
+ intent: new MutationIntent
+ {
+ OperationName = "ExampleOperation",
+ Category = category,
+ Description = "Recently approved governed request"
+ },
+ context: MutationContext.User("requester", "Requester", "Need privileged change"),
+ requirements:
+ [
+ PolicyRequirement.Approval(approverId, $"Approval required from {approverId}")
+ ])
+ with
+ {
+ RequestId = requestId,
+ Status = MutationRequestStatus.Approved,
+ PendingReason = null,
+ CreatedAt = approvedAt.AddMinutes(-20),
+ UpdatedAt = approvedAt,
+ ApprovalRequirements =
+ [
+ new MutationApprovalRequirement
+ {
+ ApproverId = approverId,
+ Status = MutationApprovalRequirementStatus.Approved,
+ StepOrder = 1,
+ DecidedAt = approvedAt
+ }
+ ],
+ Decisions =
+ [
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted),
+ MutationContext.User("requester", "Requester", "Submitted"))
+ with
+ {
+ Timestamp = approvedAt.AddMinutes(-20)
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending),
+ MutationContext.User("requester", "Requester", "Pending approval"))
+ with
+ {
+ Timestamp = approvedAt.AddMinutes(-19)
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested),
+ MutationContext.User("requester", "Requester", "Approval requested"))
+ with
+ {
+ Timestamp = approvedAt.AddMinutes(-18)
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted),
+ MutationContext.User(approverId, approverId, "Approved"))
+ with
+ {
+ Timestamp = approvedAt
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved),
+ MutationContext.User(approverId, approverId, "Approved"))
+ with
+ {
+ Timestamp = approvedAt.AddMinutes(1)
+ }
+ ]
+ };
+
+ private static MutationRequest CreateResolvedRequest(
+ string requestId,
+ string stateId,
+ string category,
+ DateTimeOffset decisionTimestamp)
+ => MutationRequestFactory.Approved(
+ stateId: stateId,
+ stateType: "ExampleState",
+ mutationType: "ExampleMutation",
+ intent: new MutationIntent
+ {
+ OperationName = "ExampleOperation",
+ Category = category,
+ Description = "Resolved governed request"
+ },
+ context: MutationContext.Service("governance-runtime", "Resolve stale request"))
+ with
+ {
+ RequestId = requestId,
+ Status = MutationRequestStatus.Approved,
+ CreatedAt = decisionTimestamp.AddMinutes(-30),
+ UpdatedAt = decisionTimestamp,
+ Decisions =
+ [
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted),
+ MutationContext.Service("governance-runtime", "Submitted"))
+ with
+ {
+ Timestamp = decisionTimestamp.AddMinutes(-30)
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.VersionResolution(
+ MutationRequestVersionResolutionDecisionType.Validated),
+ MutationContext.Service("governance-runtime", "Validated current version"))
+ with
+ {
+ Timestamp = decisionTimestamp
+ }
+ ]
+ };
+
+ private static MutationRequest CreateExecutedRequest(
+ string requestId,
+ string stateId,
+ string category,
+ DateTimeOffset executedAt)
+ => MutationRequestFactory.Approved(
+ stateId: stateId,
+ stateType: "ExampleState",
+ mutationType: "ExampleMutation",
+ intent: new MutationIntent
+ {
+ OperationName = "ExampleOperation",
+ Category = category,
+ Description = "Executed governed request"
+ },
+ context: MutationContext.Service("governance-runtime", "Execute approved request"))
+ with
+ {
+ RequestId = requestId,
+ Status = MutationRequestStatus.Executed,
+ CreatedAt = executedAt.AddMinutes(-15),
+ UpdatedAt = executedAt,
+ ExecutedAt = executedAt,
+ Decisions =
+ [
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted),
+ MutationContext.Service("governance-runtime", "Submitted"))
+ with
+ {
+ Timestamp = executedAt.AddMinutes(-15)
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved),
+ MutationContext.Service("governance-runtime", "Approved"))
+ with
+ {
+ Timestamp = executedAt.AddMinutes(-5)
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed),
+ MutationContext.Service("governance-runtime", "Executed"))
+ with
+ {
+ Timestamp = executedAt
+ }
+ ]
+ };
+}
diff --git a/Examples/Governance/Queries/Scenarios/GovernanceQueriesScenario.cs b/Examples/Governance/Queries/Scenarios/GovernanceQueriesScenario.cs
new file mode 100644
index 0000000..abaf041
--- /dev/null
+++ b/Examples/Governance/Queries/Scenarios/GovernanceQueriesScenario.cs
@@ -0,0 +1,13 @@
+namespace Queries.Scenarios;
+
+internal static class GovernanceQueriesScenario
+{
+ public static async Task Run()
+ {
+ var store = await GovernanceQueriesSampleData.CreateStoreAsync();
+
+ await RequestQueryScenario.Run(store);
+ await ApprovalQueryScenario.Run(store);
+ await DecisionQueryScenario.Run(store);
+ }
+}
diff --git a/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs b/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs
new file mode 100644
index 0000000..9df4cb5
--- /dev/null
+++ b/Examples/Governance/Queries/Scenarios/RequestQueryScenario.cs
@@ -0,0 +1,35 @@
+using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts;
+using ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
+using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
+
+namespace Queries.Scenarios;
+
+internal static class RequestQueryScenario
+{
+ public static async Task Run(IMutationRequestQueryStore queryStore)
+ {
+ GovernanceQueriesSampleData.PrintSection("Pending Approval Queue");
+ GovernanceQueriesSampleData.PrintRequests(await queryStore.GetPendingApprovalQueueAsync());
+
+ GovernanceQueriesSampleData.PrintSection("Pending External Check Requests");
+ GovernanceQueriesSampleData.PrintRequests(await queryStore.GetPendingRequestsAsync(new MutationRequestQuery
+ {
+ PendingReasons = new HashSet { PendingMutationReason.ExternalCheck }
+ }));
+
+ GovernanceQueriesSampleData.PrintSection("Billing Requests");
+ GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery
+ {
+ Categories = new HashSet { "Billing" }
+ }));
+
+ GovernanceQueriesSampleData.PrintSection("Requests For tenant-42:roles");
+ GovernanceQueriesSampleData.PrintRequests(await queryStore.QueryAsync(new MutationRequestQuery
+ {
+ StateIds = new HashSet { "tenant-42:roles" }
+ }));
+
+ GovernanceQueriesSampleData.PrintSection("Recent Approval Driven Requests");
+ GovernanceQueriesSampleData.PrintRequests(await queryStore.GetRecentApprovalsAsync(take: 3));
+ }
+}
diff --git a/Examples/Governance/RedisQueries/Program.cs b/Examples/Governance/RedisQueries/Program.cs
new file mode 100644
index 0000000..b44070c
--- /dev/null
+++ b/Examples/Governance/RedisQueries/Program.cs
@@ -0,0 +1,3 @@
+using RedisQueries.Scenarios;
+
+await GovernanceRedisQueriesScenario.Run();
diff --git a/Examples/Governance/RedisQueries/README.md b/Examples/Governance/RedisQueries/README.md
new file mode 100644
index 0000000..6317073
--- /dev/null
+++ b/Examples/Governance/RedisQueries/README.md
@@ -0,0 +1,84 @@
+# Governance RedisQueries
+
+This example shows how to use `ModularityKit.Mutator.Governance.Redis` as the backing store for governed request writes and query-oriented reads.
+
+It focuses on real Redis-backed request persistence, approval queue reads, and decision/history queries through the same provider.
+
+## What it demonstrates
+
+- connecting to Redis with `ConnectionMultiplexer`
+- registering the Redis governance provider through `AddRedisGovernanceStore(...)`
+- creating governed requests through `IMutationRequestStore`
+- reading pending request queues through `IMutationRequestQueryStore`
+- projecting approval views and decision views from Redis-backed data
+- isolating sample data with a dedicated Redis key prefix
+
+## Prerequisite
+
+You need a running Redis instance.
+
+Default connection:
+
+```bash
+localhost:6379
+```
+
+Override it with a full connection string:
+
+```bash
+export MODULARITYKIT_REDIS="your-host:6379"
+```
+
+Or with separate settings:
+
+```bash
+export MODULARITYKIT_REDIS_HOST="localhost"
+export MODULARITYKIT_REDIS_PORT="6379"
+export MODULARITYKIT_REDIS_PASSWORD=""
+```
+
+## Start Redis locally
+
+From this folder:
+
+```bash
+docker compose up -d
+```
+
+This starts a local Redis instance using [`docker-compose.yml`](docker-compose.yml).
+
+Optional overrides:
+
+```bash
+export MODULARITYKIT_REDIS_PORT="6380"
+export MODULARITYKIT_REDIS_PASSWORD="secret"
+docker compose up -d
+```
+
+Stop it with:
+
+```bash
+docker compose down
+```
+
+## Key files
+
+- [`Program.cs`](Program.cs)
+- [`Scenarios/GovernanceRedisQueriesScenario.cs`](Scenarios/GovernanceRedisQueriesScenario.cs)
+- [`src/Redis/DependencyInjection/RedisGovernanceServiceCollectionExtensions.cs`](../../../src/Redis/DependencyInjection/RedisGovernanceServiceCollectionExtensions.cs)
+- [`src/Redis/Storage/RedisMutationRequestStore.cs`](../../../src/Redis/Storage/RedisMutationRequestStore.cs)
+
+## Run
+
+```bash
+dotnet run --project Examples/Governance/RedisQueries/RedisQueries.csproj -c Release
+```
+
+## Expected output
+
+The sample prints:
+
+- the Redis connection and key prefix used by the run
+- pending approval queue entries
+- approval views filtered by approver
+- recent decision views for execution outcomes
diff --git a/Examples/Governance/RedisQueries/RedisQueries.csproj b/Examples/Governance/RedisQueries/RedisQueries.csproj
new file mode 100644
index 0000000..108e989
--- /dev/null
+++ b/Examples/Governance/RedisQueries/RedisQueries.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs
new file mode 100644
index 0000000..f5601e6
--- /dev/null
+++ b/Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs
@@ -0,0 +1,220 @@
+using Microsoft.Extensions.DependencyInjection;
+using ModularityKit.Mutator.Abstractions.Context;
+using ModularityKit.Mutator.Abstractions.Intent;
+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.Requests.Decisions;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
+using ModularityKit.Mutator.Governance.Abstractions.Storage;
+using ModularityKit.Mutator.Governance.Redis;
+using StackExchange.Redis;
+
+namespace RedisQueries.Scenarios;
+
+internal static class GovernanceRedisQueriesScenario
+{
+ public static async Task Run()
+ {
+ var redisConnectionString = BuildRedisConnectionString();
+ var keyPrefix = $"modularitykit:examples:governance:redis:{Guid.NewGuid():N}";
+
+ try
+ {
+ await using var multiplexer = await ConnectionMultiplexer.ConnectAsync(redisConnectionString);
+ var services = new ServiceCollection();
+
+ services.AddRedisGovernanceStore(
+ multiplexer,
+ options => options.KeyPrefix = keyPrefix);
+
+ await using var provider = services.BuildServiceProvider();
+ var requestStore = provider.GetRequiredService();
+ var queryStore = provider.GetRequiredService();
+
+ await Seed(requestStore);
+
+ Console.WriteLine($"Redis: {redisConnectionString}");
+ Console.WriteLine($"KeyPrefix: {keyPrefix}");
+
+ PrintSection("Pending Approval Queue");
+ PrintRequests(await queryStore.GetPendingApprovalQueueAsync());
+
+ PrintSection("Pending Approvals For security-lead");
+ PrintApprovals(await queryStore.GetPendingApprovalsAsync(new MutationApprovalQuery
+ {
+ ApproverIds = new HashSet { "security-lead" }
+ }));
+
+ PrintSection("Recent Execution Outcomes");
+ PrintDecisions(await queryStore.GetRecentDecisionsAsync(
+ MutationRequestDecisionQuery.RecentExecutionOutcomes(),
+ take: 5));
+ }
+ catch (RedisConnectionException exception)
+ {
+ Console.Error.WriteLine($"Could not connect to Redis at '{redisConnectionString}'.");
+ Console.Error.WriteLine(exception.Message);
+ Console.Error.WriteLine("Start Redis locally or set MODULARITYKIT_REDIS to a reachable endpoint.");
+ }
+ }
+
+ private static string BuildRedisConnectionString()
+ {
+ var explicitConnectionString = Environment.GetEnvironmentVariable("MODULARITYKIT_REDIS");
+ if (!string.IsNullOrWhiteSpace(explicitConnectionString))
+ return explicitConnectionString;
+
+ var host = Environment.GetEnvironmentVariable("MODULARITYKIT_REDIS_HOST") ?? "localhost";
+ var port = Environment.GetEnvironmentVariable("MODULARITYKIT_REDIS_PORT") ?? "6379";
+ var password = Environment.GetEnvironmentVariable("MODULARITYKIT_REDIS_PASSWORD");
+
+ return string.IsNullOrWhiteSpace(password)
+ ? $"{host}:{port}"
+ : $"{host}:{port},password={password}";
+ }
+
+ private static async Task Seed(IMutationRequestStore requestStore)
+ {
+ await requestStore.Create(CreatePendingApprovalRequest(
+ requestId: "redis-req-security-1",
+ stateId: "tenant-42:roles",
+ category: "Security",
+ approverId: "security-lead",
+ createdAt: new DateTimeOffset(2026, 6, 25, 8, 0, 0, TimeSpan.Zero)));
+
+ await requestStore.Create(CreatePendingApprovalRequest(
+ requestId: "redis-req-billing-1",
+ stateId: "tenant-42:quota",
+ category: "Billing",
+ approverId: "billing-owner",
+ createdAt: new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero)));
+
+ await requestStore.Create(CreateExecutedRequest(
+ requestId: "redis-req-executed-1",
+ stateId: "tenant-42:quota",
+ category: "Billing",
+ executedAt: new DateTimeOffset(2026, 6, 25, 12, 0, 0, TimeSpan.Zero)));
+ }
+
+ private static MutationRequest CreatePendingApprovalRequest(
+ string requestId,
+ string stateId,
+ string category,
+ string approverId,
+ DateTimeOffset createdAt)
+ => MutationRequestFactory.PendingApproval(
+ stateId: stateId,
+ stateType: "ExampleState",
+ mutationType: "ExampleMutation",
+ intent: new MutationIntent
+ {
+ OperationName = "ExampleOperation",
+ Category = category,
+ Description = $"Redis-backed governed request for {category.ToLowerInvariant()} flow"
+ },
+ context: MutationContext.User("requester", "Requester", "Need governed change"),
+ requirements:
+ [
+ PolicyRequirement.Approval(approverId, $"Approval required from {approverId}")
+ ])
+ with
+ {
+ RequestId = requestId,
+ CreatedAt = createdAt,
+ UpdatedAt = createdAt
+ };
+
+ private static MutationRequest CreateExecutedRequest(
+ string requestId,
+ string stateId,
+ string category,
+ DateTimeOffset executedAt)
+ => MutationRequestFactory.Approved(
+ stateId: stateId,
+ stateType: "ExampleState",
+ mutationType: "ExampleMutation",
+ intent: new MutationIntent
+ {
+ OperationName = "ExampleOperation",
+ Category = category,
+ Description = "Executed governed request"
+ },
+ context: MutationContext.Service("governance-runtime", "Execute approved request"))
+ with
+ {
+ RequestId = requestId,
+ Status = MutationRequestStatus.Executed,
+ CreatedAt = executedAt.AddMinutes(-15),
+ UpdatedAt = executedAt,
+ ExecutedAt = executedAt,
+ Decisions =
+ [
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted),
+ MutationContext.Service("governance-runtime", "Submitted"))
+ with
+ {
+ Timestamp = executedAt.AddMinutes(-15)
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved),
+ MutationContext.Service("governance-runtime", "Approved"))
+ with
+ {
+ Timestamp = executedAt.AddMinutes(-5)
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed),
+ MutationContext.Service("governance-runtime", "Executed"))
+ with
+ {
+ Timestamp = executedAt
+ }
+ ]
+ };
+
+ private static void PrintSection(string title)
+ {
+ Console.WriteLine();
+ Console.WriteLine($"=== {title} ===");
+ }
+
+ private static void PrintRequests(IReadOnlyList requests)
+ {
+ foreach (var request in requests)
+ {
+ Console.WriteLine(
+ $"- {request.RequestId} | {request.StateId} | {request.Intent.Category} | {request.Status} | pending: {request.PendingReason?.ToString() ?? "-"}");
+ }
+
+ if (requests.Count == 0)
+ Console.WriteLine("- none");
+ }
+
+ private static void PrintApprovals(IReadOnlyList approvals)
+ {
+ foreach (var approval in approvals)
+ {
+ Console.WriteLine(
+ $"- {approval.Request.RequestId} | {approval.Request.Intent.Category} | approver: {approval.Approval.ApproverId} | status: {approval.Approval.Status}");
+ }
+
+ if (approvals.Count == 0)
+ Console.WriteLine("- none");
+ }
+
+ private static void PrintDecisions(IReadOnlyList decisions)
+ {
+ foreach (var decision in decisions)
+ {
+ Console.WriteLine(
+ $"- {decision.Request.RequestId} | {decision.Decision.Type.Category}:{decision.Decision.Type.Code} | {decision.Decision.Timestamp:O}");
+ }
+
+ if (decisions.Count == 0)
+ Console.WriteLine("- none");
+ }
+}
diff --git a/Examples/Governance/RedisQueries/docker-compose.yml b/Examples/Governance/RedisQueries/docker-compose.yml
new file mode 100644
index 0000000..450e937
--- /dev/null
+++ b/Examples/Governance/RedisQueries/docker-compose.yml
@@ -0,0 +1,16 @@
+services:
+ redis:
+ image: redis:7-alpine
+ ports:
+ - "${MODULARITYKIT_REDIS_PORT:-6379}:6379"
+ environment:
+ REDIS_PASSWORD: "${MODULARITYKIT_REDIS_PASSWORD:-}"
+ command:
+ - sh
+ - -c
+ - >
+ if [ -n "$$REDIS_PASSWORD" ]; then
+ exec redis-server --save "" --appendonly no --requirepass "$$REDIS_PASSWORD";
+ else
+ exec redis-server --save "" --appendonly no;
+ fi
diff --git a/Examples/README.md b/Examples/README.md
index 9293401..443d047 100644
--- a/Examples/README.md
+++ b/Examples/README.md
@@ -27,6 +27,8 @@ The projects are intentionally small and focused. Each one demonstrates a differ
| `DecisionTaxonomy` | lifecycle, approval, and version-resolution decision categories | [`Examples/Governance/DecisionTaxonomy/README.md`](Governance/DecisionTaxonomy/README.md) |
| `ApprovalWorkflow` | request-level approvals, multi-step sign-off, and governed approval actions | [`Examples/Governance/ApprovalWorkflow/README.md`](Governance/ApprovalWorkflow/README.md) |
| `VersionedResolution` | stale request handling and expected state version semantics | [`Examples/Governance/VersionedResolution/README.md`](Governance/VersionedResolution/README.md) |
+| `Queries` | request queries, pending approvals, and decision-oriented governance read views | [`Examples/Governance/Queries/README.md`](Governance/Queries/README.md) |
+| `RedisQueries` | Redis-backed governance request storage and query-oriented reads | [`Examples/Governance/RedisQueries/README.md`](Governance/RedisQueries/README.md) |
## How to use these examples
@@ -59,6 +61,8 @@ dotnet build Examples/Governance/GovernedExecution/GovernedExecution.csproj -c R
dotnet build Examples/Governance/DecisionTaxonomy/DecisionTaxonomy.csproj -c Release
dotnet build Examples/Governance/ApprovalWorkflow/ApprovalWorkflow.csproj -c Release
dotnet build Examples/Governance/VersionedResolution/VersionedResolution.csproj -c Release
+dotnet build Examples/Governance/Queries/Queries.csproj -c Release
+dotnet build Examples/Governance/RedisQueries/RedisQueries.csproj -c Release
```
## Run
@@ -77,6 +81,8 @@ dotnet run --project Examples/Governance/GovernedExecution/GovernedExecution.csp
dotnet run --project Examples/Governance/DecisionTaxonomy/DecisionTaxonomy.csproj
dotnet run --project Examples/Governance/ApprovalWorkflow/ApprovalWorkflow.csproj
dotnet run --project Examples/Governance/VersionedResolution/VersionedResolution.csproj
+dotnet run --project Examples/Governance/Queries/Queries.csproj
+dotnet run --project Examples/Governance/RedisQueries/RedisQueries.csproj
```
If you want to run one sample repeatedly while changing code, stay in its folder:
@@ -164,6 +170,18 @@ Shows how governance resolves approved requests once the underlying state versio
See [`Governance/VersionedResolution/README.md`](Governance/VersionedResolution/README.md).
+### Queries
+
+Shows the governance read side as a first-class API for listing requests, approvals, and decision history.
+
+See [`Governance/Queries/README.md`](Governance/Queries/README.md).
+
+### RedisQueries
+
+Shows the same governance read side backed by `ModularityKit.Mutator.Governance.Redis` instead of the in-memory store.
+
+See [`Governance/RedisQueries/README.md`](Governance/RedisQueries/README.md).
+
## Notes
- The examples are separate console applications, not libraries.
diff --git a/ModularityKit.Mutator.sln.DotSettings b/ModularityKit.Mutator.sln.DotSettings
index 82abf71..1f1e9c1 100644
--- a/ModularityKit.Mutator.sln.DotSettings
+++ b/ModularityKit.Mutator.sln.DotSettings
@@ -1,2 +1,3 @@
- True
\ No newline at end of file
+ True
+ True
\ No newline at end of file
diff --git a/ModularityKit.Mutator.sln.DotSettings.user b/ModularityKit.Mutator.sln.DotSettings.user
index d2cff9e..f0be223 100644
--- a/ModularityKit.Mutator.sln.DotSettings.user
+++ b/ModularityKit.Mutator.sln.DotSettings.user
@@ -1,2 +1,3 @@
- ForceIncluded
\ No newline at end of file
+ ForceIncluded
+ ForceIncluded
\ No newline at end of file
diff --git a/ModularityKit.Mutator.slnx b/ModularityKit.Mutator.slnx
index f83eb84..7e1be38 100644
--- a/ModularityKit.Mutator.slnx
+++ b/ModularityKit.Mutator.slnx
@@ -2,6 +2,7 @@
+
@@ -11,12 +12,15 @@
+
+
+
diff --git a/README.md b/README.md
index dd33e54..eda9f01 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@
- [`ModularityKit.Mutator`](src/README.md) - core mutation runtime
- [`ModularityKit.Mutator.Governance`](src/Governance/README.md) - request lifecycle, approvals, and governed execution
+- [`ModularityKit.Mutator.Governance.Redis`](src/Redis/README.md) - Redis provider for ModularityKit.Mutator.Governance
## Repository
diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Configuration/RedisMutationRequestStoreOptionsTests.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Configuration/RedisMutationRequestStoreOptionsTests.cs
new file mode 100644
index 0000000..e3eebfb
--- /dev/null
+++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Configuration/RedisMutationRequestStoreOptionsTests.cs
@@ -0,0 +1,16 @@
+using ModularityKit.Mutator.Governance.Redis;
+using ModularityKit.Mutator.Governance.Redis.Configuration;
+using Xunit;
+
+namespace ModularityKit.Mutator.Governance.Redis.Tests.Configuration;
+
+public sealed class RedisMutationRequestStoreOptionsTests
+{
+ [Fact]
+ public void Uses_expected_default_key_prefix()
+ {
+ var options = new RedisMutationRequestStoreOptions();
+
+ Assert.Equal("modularitykit:governance", options.KeyPrefix);
+ }
+}
diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceTests.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceTests.cs
new file mode 100644
index 0000000..87441f6
--- /dev/null
+++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Keys/RedisMutationRequestKeyspaceTests.cs
@@ -0,0 +1,67 @@
+using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
+using ModularityKit.Mutator.Governance.Redis;
+using ModularityKit.Mutator.Governance.Redis.Configuration;
+using ModularityKit.Mutator.Governance.Redis.Keys;
+using Xunit;
+
+namespace ModularityKit.Mutator.Governance.Redis.Tests.Keys;
+
+public sealed class RedisMutationRequestKeyspaceTests
+{
+ [Fact]
+ public void Builds_expected_request_keys_from_prefix()
+ {
+ var keyspace = new RedisMutationRequestKeyspace(new RedisMutationRequestStoreOptions
+ {
+ KeyPrefix = "mk:gov"
+ });
+
+ Assert.Equal("mk:gov:requests:ids", keyspace.RequestIds().ToString());
+ Assert.Equal("mk:gov:requests:req-42:data", keyspace.RequestData("req-42").ToString());
+ Assert.Equal("mk:gov:requests:req-42:revision", keyspace.RequestRevision("req-42").ToString());
+ }
+
+ [Fact]
+ public void Builds_expected_index_keys_for_state_status_and_pending_reason()
+ {
+ var keyspace = new RedisMutationRequestKeyspace(new RedisMutationRequestStoreOptions
+ {
+ KeyPrefix = "mk:gov"
+ });
+
+ Assert.Equal("mk:gov:states:tenant-42:requests", keyspace.RequestsByStateId("tenant-42").ToString());
+ Assert.Equal("mk:gov:status:pending:requests", keyspace.RequestsByStatus(MutationRequestStatus.Pending).ToString());
+ Assert.Equal("mk:gov:pending:requests", keyspace.PendingRequestIds().ToString());
+ Assert.Equal(
+ "mk:gov:pending:approval:requests",
+ keyspace.PendingRequestIds(PendingMutationReason.Approval).ToString());
+ }
+
+ [Fact]
+ public void Enumerate_indexes_includes_pending_indexes_only_for_pending_requests()
+ {
+ var keyspace = new RedisMutationRequestKeyspace(new RedisMutationRequestStoreOptions
+ {
+ KeyPrefix = "mk:gov"
+ });
+
+ var request = new MutationRequest
+ {
+ RequestId = "req-42",
+ StateId = "tenant-42",
+ StateType = "IamRoleState",
+ MutationType = "GrantRoleMutation",
+ Status = MutationRequestStatus.Pending,
+ PendingReason = PendingMutationReason.Approval
+ };
+
+ var keys = keyspace.EnumerateIndexes(request).Select(key => key.ToString()).ToArray();
+
+ Assert.Contains("mk:gov:requests:ids", keys);
+ Assert.Contains("mk:gov:states:tenant-42:requests", keys);
+ Assert.Contains("mk:gov:status:pending:requests", keys);
+ Assert.Contains("mk:gov:pending:requests", keys);
+ Assert.Contains("mk:gov:pending:approval:requests", keys);
+ }
+}
diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/ModularityKit.Mutator.Governance.Redis.Tests.csproj b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/ModularityKit.Mutator.Governance.Redis.Tests.csproj
new file mode 100644
index 0000000..e09acff
--- /dev/null
+++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/ModularityKit.Mutator.Governance.Redis.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs
new file mode 100644
index 0000000..f62620f
--- /dev/null
+++ b/Tests/ModularityKit.Mutator.Governance.Redis.Tests/Serialization/Converters/RedisMutationRequestSerializerTests.cs
@@ -0,0 +1,82 @@
+using ModularityKit.Mutator.Abstractions.Context;
+using ModularityKit.Mutator.Abstractions.Intent;
+using ModularityKit.Mutator.Abstractions.Policies;
+using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
+using ModularityKit.Mutator.Governance.Redis.Serialization;
+using Xunit;
+
+namespace ModularityKit.Mutator.Governance.Redis.Tests.Serialization;
+
+public sealed class RedisMutationRequestSerializerTests
+{
+ [Fact]
+ public void Roundtrip_preserves_request_shape_needed_by_governance_runtime()
+ {
+ var request = MutationRequestFactory.PendingApproval(
+ stateId: "tenant-42:roles",
+ stateType: "IamRoleState",
+ mutationType: "GrantRoleMutation",
+ intent: new MutationIntent
+ {
+ OperationName = "GrantRole",
+ Category = "Security",
+ Description = "Grant elevated access",
+ Tags = new HashSet { "security", "urgent" },
+ EstimatedBlastRadius = BlastRadius.Module,
+ Metadata = new Dictionary
+ {
+ ["risk-owner"] = "platform"
+ }
+ },
+ context: MutationContext.User("requester-1", "Requester One", "Need emergency access") with
+ {
+ StateId = "tenant-42:roles",
+ Metadata = new Dictionary
+ {
+ ["source"] = "tests"
+ }
+ },
+ requirements:
+ [
+ new PolicyRequirement
+ {
+ Type = "Approval",
+ Description = "Requires security approval",
+ Data = new Dictionary
+ {
+ ["Approver"] = "security-lead",
+ ["Reason"] = "Elevated role",
+ ["StepOrder"] = 1L,
+ ["RequiredApprovals"] = 1L
+ }
+ }
+ ],
+ expectedStateVersion: "v10",
+ metadata: new Dictionary
+ {
+ ["team"] = "security",
+ ["priority"] = "high"
+ })
+ with
+ {
+ CreatedAt = new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero),
+ UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero)
+ };
+
+ var json = RedisMutationRequestSerializer.Serialize(request);
+ var roundtrip = RedisMutationRequestSerializer.Deserialize(json);
+
+ Assert.Equal(request.RequestId, roundtrip.RequestId);
+ Assert.Equal(request.Status, roundtrip.Status);
+ Assert.Equal(request.PendingReason, roundtrip.PendingReason);
+ Assert.Equal(request.Intent.Category, roundtrip.Intent.Category);
+ Assert.Contains("security", roundtrip.Intent.Tags);
+ Assert.Equal(BlastRadiusScope.Module, roundtrip.Intent.EstimatedBlastRadius?.Scope);
+ Assert.Equal("security", roundtrip.Metadata["team"]);
+ Assert.Single(roundtrip.Requirements);
+ Assert.Single(roundtrip.ApprovalRequirements);
+ Assert.Equal("security-lead", roundtrip.ApprovalRequirements[0].ApproverId);
+ Assert.Equal(3, roundtrip.Decisions.Count);
+ }
+}
diff --git a/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs
new file mode 100644
index 0000000..ce202b0
--- /dev/null
+++ b/Tests/ModularityKit.Mutator.Governance.Tests/Queries/MutationRequestQueryStoreTests.cs
@@ -0,0 +1,421 @@
+using ModularityKit.Mutator.Abstractions.Context;
+using ModularityKit.Mutator.Abstractions.Intent;
+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.Requests.Decisions;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
+using ModularityKit.Mutator.Governance.Runtime.Storage;
+using Xunit;
+
+namespace ModularityKit.Mutator.Governance.Tests.Queries;
+
+public sealed class MutationRequestQueryStoreTests
+{
+ [Fact]
+ public async Task QueryAsync_filters_requests_by_governance_dimensions()
+ {
+ var store = new InMemoryMutationRequestStore();
+ var approvalRequest = await store.Create(CreateGovernedRequest(
+ requestId: "req-approval",
+ stateId: "tenant-42:roles",
+ stateType: "IamRoleState",
+ mutationType: "GrantRoleMutation",
+ actorId: "alice",
+ actorName: "Alice",
+ category: "Security",
+ tags: new HashSet { "security", "urgent" },
+ metadata: 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),
+ status: MutationRequestStatus.Pending,
+ pendingReason: PendingMutationReason.Approval,
+ decisions:
+ [
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted),
+ MutationContext.User("alice", "Alice", "Need review")),
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending),
+ MutationContext.User("alice", "Alice", "Pending approval")),
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested),
+ MutationContext.User("alice", "Alice", "Approval requested"))
+ ]));
+
+ await store.Create(CreateGovernedRequest(
+ requestId: "req-other",
+ stateId: "tenant-42:billing",
+ stateType: "QuotaState",
+ mutationType: "IncreaseQuotaMutation",
+ actorId: "bob",
+ actorName: "Bob",
+ category: "Billing",
+ tags: new HashSet { "billing" },
+ metadata: 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),
+ status: MutationRequestStatus.Approved,
+ pendingReason: null,
+ decisions:
+ [
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted),
+ MutationContext.User("bob", "Bob", "Request submitted")),
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved),
+ MutationContext.User("bob", "Bob", "Approved"))
+ ]));
+
+ 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)
+ });
+
+ Assert.Single(results);
+ Assert.Equal(approvalRequest.RequestId, results[0].RequestId);
+ }
+
+ [Fact]
+ public async Task GetPendingRequestsAsync_returns_only_pending_requests()
+ {
+ var store = new InMemoryMutationRequestStore();
+ var pending = await store.Create(CreateSimpleRequest(
+ "req-pending",
+ MutationRequestStatus.Pending,
+ PendingMutationReason.ExternalCheck,
+ new DateTimeOffset(2026, 6, 1, 8, 0, 0, TimeSpan.Zero)));
+
+ await store.Create(CreateSimpleRequest(
+ "req-approved",
+ MutationRequestStatus.Approved,
+ null,
+ new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero)));
+
+ var results = await store.GetPendingRequestsAsync();
+
+ Assert.Single(results);
+ Assert.Equal(pending.RequestId, results[0].RequestId);
+ }
+
+ [Fact]
+ public async Task GetPendingApprovalQueueAsync_and_GetRecentApprovalsAsync_return_approval_oriented_views()
+ {
+ var store = new InMemoryMutationRequestStore();
+
+ var pendingApproval = await store.Create(CreateSimpleRequest(
+ "req-pending-approval",
+ MutationRequestStatus.Pending,
+ PendingMutationReason.Approval,
+ new DateTimeOffset(2026, 6, 1, 8, 0, 0, TimeSpan.Zero)));
+
+ var recentApproval = await store.Create(CreateApprovedRequest(
+ "req-recent-approval",
+ new DateTimeOffset(2026, 6, 2, 8, 0, 0, TimeSpan.Zero),
+ new DateTimeOffset(2026, 6, 2, 9, 15, 0, TimeSpan.Zero)));
+
+ await store.Create(CreateApprovedRequest(
+ "req-older-approval",
+ new DateTimeOffset(2026, 6, 1, 8, 0, 0, TimeSpan.Zero),
+ new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero)));
+
+ var pendingQueue = await store.GetPendingApprovalQueueAsync();
+ var recentApprovals = await store.GetRecentApprovalsAsync(take: 1);
+
+ Assert.Single(pendingQueue);
+ Assert.Equal(pendingApproval.RequestId, pendingQueue[0].RequestId);
+
+ Assert.Single(recentApprovals);
+ Assert.Equal(recentApproval.RequestId, recentApprovals[0].RequestId);
+ }
+
+ [Fact]
+ public async Task GetPendingApprovalsAsync_filters_approval_views_by_approver_dimensions()
+ {
+ var store = new InMemoryMutationRequestStore();
+
+ await store.Create(CreateApprovalViewRequest(
+ requestId: "req-security",
+ approverId: "security-lead",
+ approverRole: "SecurityLead",
+ approverGroup: "security",
+ category: "Security",
+ approvalStatus: MutationApprovalRequirementStatus.Pending));
+
+ await store.Create(CreateApprovalViewRequest(
+ requestId: "req-platform",
+ approverId: "platform-owner",
+ approverRole: "PlatformOwner",
+ approverGroup: "platform",
+ category: "Platform",
+ approvalStatus: MutationApprovalRequirementStatus.Pending));
+
+ var approvals = await store.GetPendingApprovalsAsync(new MutationApprovalQuery
+ {
+ ApproverIds = new HashSet { "security-lead" },
+ ApproverRoles = new HashSet { "SecurityLead" },
+ ApproverGroups = new HashSet { "security" },
+ Categories = new HashSet { "Security" }
+ });
+
+ Assert.Single(approvals);
+ Assert.Equal("req-security", approvals[0].Request.RequestId);
+ Assert.Equal("security-lead", approvals[0].Approval.ApproverId);
+ }
+
+ [Fact]
+ public async Task GetRecentDecisionsAsync_returns_filtered_decision_views()
+ {
+ var store = new InMemoryMutationRequestStore();
+
+ await store.Create(CreateDecisionViewRequest(
+ requestId: "req-resolution",
+ decisions:
+ [
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.VersionResolution(
+ MutationRequestVersionResolutionDecisionType.RejectedAsStale),
+ MutationContext.User("resolver", "Resolver", "Rejected as stale"))
+ with
+ {
+ Timestamp = new DateTimeOffset(2026, 6, 3, 12, 0, 0, TimeSpan.Zero)
+ }
+ ]));
+
+ await store.Create(CreateDecisionViewRequest(
+ requestId: "req-executed",
+ decisions:
+ [
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Executed),
+ MutationContext.System("Executed"))
+ with
+ {
+ Timestamp = new DateTimeOffset(2026, 6, 3, 13, 0, 0, TimeSpan.Zero)
+ }
+ ]));
+
+ var decisions = await store.GetRecentDecisionsAsync(
+ MutationRequestDecisionQuery.RecentVersionResolutions() with
+ {
+ ActorIds = new HashSet { "resolver" }
+ },
+ take: 5);
+
+ Assert.Single(decisions);
+ Assert.Equal("req-resolution", decisions[0].Request.RequestId);
+ Assert.Equal(MutationRequestDecisionCategory.VersionResolution, decisions[0].Decision.Type.Category);
+ }
+
+ private static MutationRequest CreateSimpleRequest(
+ string requestId,
+ MutationRequestStatus status,
+ PendingMutationReason? pendingReason,
+ DateTimeOffset createdAt)
+ => MutationRequestFactory.Pending(
+ stateId: "tenant-42:quota",
+ stateType: "QuotaState",
+ mutationType: "IncreaseQuotaMutation",
+ intent: new MutationIntent
+ {
+ OperationName = "IncreaseQuota",
+ Category = "Billing",
+ Description = "Raise quota",
+ Tags = new HashSet { "billing" },
+ EstimatedBlastRadius = BlastRadius.Single
+ },
+ context: MutationContext.User("alice", "Alice", "Need more quota"),
+ pendingReason: pendingReason ?? PendingMutationReason.Approval)
+ with
+ {
+ RequestId = requestId,
+ Status = status,
+ PendingReason = pendingReason,
+ CreatedAt = createdAt,
+ UpdatedAt = createdAt
+ };
+
+ private static MutationRequest CreateApprovedRequest(
+ string requestId,
+ DateTimeOffset createdAt,
+ DateTimeOffset updatedAt)
+ => MutationRequestFactory.PendingApproval(
+ stateId: "tenant-42:roles",
+ stateType: "IamRoleState",
+ mutationType: "GrantRoleMutation",
+ intent: new MutationIntent
+ {
+ OperationName = "GrantRole",
+ Category = "Security",
+ Description = "Grant elevated access",
+ Tags = new HashSet { "security" },
+ EstimatedBlastRadius = BlastRadius.Module
+ },
+ context: MutationContext.User("requester", "Requester", "Need access"),
+ requirements:
+ [
+ PolicyRequirement.Approval("approver", "Review elevated access")
+ ])
+ with
+ {
+ RequestId = requestId,
+ Status = MutationRequestStatus.Approved,
+ PendingReason = null,
+ CreatedAt = createdAt,
+ UpdatedAt = updatedAt,
+ Decisions =
+ [
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Submitted),
+ MutationContext.User("requester", "Requester", "Submitted"))
+ with
+ {
+ Timestamp = createdAt
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Pending),
+ MutationContext.User("requester", "Requester", "Pending approval"))
+ with
+ {
+ Timestamp = createdAt.AddMinutes(5)
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Requested),
+ MutationContext.User("requester", "Requester", "Approval requested"),
+ metadata: new Dictionary { ["Queue"] = "security" })
+ with
+ {
+ Timestamp = createdAt.AddMinutes(10)
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Approval(MutationRequestApprovalDecisionType.Granted),
+ MutationContext.User("approver", "Approver", "Approved"),
+ metadata: new Dictionary { ["Queue"] = "security" })
+ with
+ {
+ Timestamp = updatedAt
+ },
+ MutationRequestDecision.Create(
+ MutationRequestDecisionType.Lifecycle(MutationRequestLifecycleDecisionType.Approved),
+ MutationContext.User("approver", "Approver", "Approved"),
+ metadata: new Dictionary { ["Queue"] = "security" })
+ with
+ {
+ Timestamp = updatedAt.AddMinutes(1)
+ }
+ ]
+ };
+
+ private static MutationRequest CreateApprovalViewRequest(
+ string requestId,
+ string approverId,
+ string approverRole,
+ string approverGroup,
+ string category,
+ MutationApprovalRequirementStatus approvalStatus)
+ => MutationRequestFactory.PendingApproval(
+ stateId: "tenant-42:roles",
+ stateType: "IamRoleState",
+ mutationType: "GrantRoleMutation",
+ intent: new MutationIntent
+ {
+ OperationName = "GrantRole",
+ Category = category,
+ Tags = new HashSet { "approval" }
+ },
+ context: MutationContext.User("requester", "Requester", "Need approval"),
+ requirements:
+ [
+ PolicyRequirement.Approval(approverId, "Need review")
+ ])
+ with
+ {
+ RequestId = requestId,
+ Status = MutationRequestStatus.Pending,
+ PendingReason = PendingMutationReason.Approval,
+ ApprovalRequirements =
+ [
+ new MutationApprovalRequirement
+ {
+ ApproverId = approverId,
+ ApproverRole = approverRole,
+ ApproverGroup = approverGroup,
+ Status = approvalStatus,
+ StepOrder = 1
+ }
+ ]
+ };
+
+ private static MutationRequest CreateDecisionViewRequest(
+ string requestId,
+ IReadOnlyList decisions)
+ => MutationRequestFactory.Pending(
+ stateId: "tenant-42:roles",
+ stateType: "IamRoleState",
+ mutationType: "GrantRoleMutation",
+ intent: new MutationIntent
+ {
+ OperationName = "GrantRole",
+ Category = "Security"
+ },
+ context: MutationContext.User("requester", "Requester", "Need execution"),
+ pendingReason: PendingMutationReason.ExternalCheck)
+ with
+ {
+ RequestId = requestId,
+ Decisions = decisions,
+ UpdatedAt = decisions.Max(decision => decision.Timestamp)
+ };
+
+ private static MutationRequest CreateGovernedRequest(
+ string requestId,
+ string stateId,
+ string stateType,
+ string mutationType,
+ string actorId,
+ string actorName,
+ string category,
+ IReadOnlySet tags,
+ IReadOnlyDictionary metadata,
+ BlastRadius blastRadius,
+ DateTimeOffset createdAt,
+ DateTimeOffset updatedAt,
+ MutationRequestStatus status,
+ PendingMutationReason? pendingReason,
+ IReadOnlyList decisions)
+ => new MutationRequest
+ {
+ RequestId = requestId,
+ StateId = stateId,
+ StateType = stateType,
+ MutationType = mutationType,
+ Intent = new MutationIntent
+ {
+ OperationName = mutationType,
+ Category = category,
+ Tags = tags,
+ Metadata = metadata,
+ EstimatedBlastRadius = blastRadius
+ },
+ Context = MutationContext.User(actorId, actorName, "Query test"),
+ Status = status,
+ PendingReason = pendingReason,
+ CreatedAt = createdAt,
+ UpdatedAt = updatedAt,
+ Decisions = decisions,
+ Metadata = metadata
+ };
+}
diff --git a/assets/governance/providers/logotype_efcore.png b/assets/governance/providers/logotype_efcore.png
new file mode 100644
index 0000000..a29100f
Binary files /dev/null and b/assets/governance/providers/logotype_efcore.png differ
diff --git a/assets/governance/providers/logotype_redis.png b/assets/governance/providers/logotype_redis.png
new file mode 100644
index 0000000..3dc58e9
Binary files /dev/null and b/assets/governance/providers/logotype_redis.png differ
diff --git a/assets/governance/providers/modularitykit-mutator-governance-redis-128.png b/assets/governance/providers/modularitykit-mutator-governance-redis-128.png
new file mode 100644
index 0000000..905c03e
Binary files /dev/null and b/assets/governance/providers/modularitykit-mutator-governance-redis-128.png differ
diff --git a/assets/governance/providers/mutator-ext-banner.png b/assets/governance/providers/mutator-ext-banner.png
new file mode 100644
index 0000000..90ff6ff
Binary files /dev/null and b/assets/governance/providers/mutator-ext-banner.png differ
diff --git a/assets/governance/providers/mutator-governance-redis-overview.png b/assets/governance/providers/mutator-governance-redis-overview.png
new file mode 100644
index 0000000..2041adc
Binary files /dev/null and b/assets/governance/providers/mutator-governance-redis-overview.png differ
diff --git a/assets/governance/providers/redis-overview.png b/assets/governance/providers/redis-overview.png
new file mode 100644
index 0000000..0fda97c
Binary files /dev/null and b/assets/governance/providers/redis-overview.png differ
diff --git a/src/Governance/Abstractions/Approval/Mapping/MutationApprovalRequirementMapper.cs b/src/Governance/Abstractions/Approval/Mapping/MutationApprovalRequirementMapper.cs
index d164fc4..30391d5 100644
--- a/src/Governance/Abstractions/Approval/Mapping/MutationApprovalRequirementMapper.cs
+++ b/src/Governance/Abstractions/Approval/Mapping/MutationApprovalRequirementMapper.cs
@@ -7,6 +7,17 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Approval.Mapping;
internal static class MutationApprovalRequirementMapper
{
+ private sealed record ApprovalRequirementConfig(
+ int StepOrder,
+ string? ApproverName,
+ string? Reason,
+ string? ApprovalGroupId,
+ string? ApproverRole,
+ string? ApproverGroup,
+ int RequiredApprovals,
+ DateTimeOffset? ExpiresAt,
+ IReadOnlyList Approvers);
+
public static IReadOnlyList Map(
IReadOnlyList? requirements)
{
@@ -33,140 +44,174 @@ private static IReadOnlyList ExtractApprovalDefinit
PolicyRequirement requirement,
int defaultStepOrder)
{
- var stepOrder = ReadIntProperty(requirement.Data, "StepOrder") ?? defaultStepOrder + 1;
- var approverName = ReadStringProperty(requirement.Data, "ApproverName");
- var reason = ReadStringProperty(requirement.Data, "Reason");
- var approvalGroupId = ReadStringProperty(requirement.Data, "ApprovalGroupId")
- ?? ReadStringProperty(requirement.Data, "GroupId");
- var approverRole = ReadStringProperty(requirement.Data, "ApproverRole");
- var approverGroup = ReadStringProperty(requirement.Data, "ApproverGroup");
- var requiredApprovals = ReadIntProperty(requirement.Data, "RequiredApprovals")
- ?? ReadIntProperty(requirement.Data, "Quorum")
- ?? 1;
- var expiresAt = ReadDateTimeOffsetProperty(requirement.Data, "ExpiresAt");
-
- var approvers = ReadStringSequenceProperty(requirement.Data, "Approvers");
- if (approvers.Count == 0)
- {
- var approver = ReadStringProperty(requirement.Data, "Approver");
- if (!string.IsNullOrWhiteSpace(approver))
- approvers = [approver];
- }
-
- var targetCount = approvers.Count
- + (!string.IsNullOrWhiteSpace(approverRole) ? 1 : 0)
- + (!string.IsNullOrWhiteSpace(approverGroup) ? 1 : 0);
+ var config = ReadApprovalRequirementConfig(requirement, defaultStepOrder);
+ var targetCount = CountTargets(config);
if (targetCount == 0)
throw new InvalidMutationApprovalConfigurationException(
$"Approval requirement '{requirement.Description}' does not define an approver, approver role, or approver group.");
- if (requiredApprovals <= 0)
+ if (config.RequiredApprovals <= 0)
throw new InvalidMutationApprovalConfigurationException(
$"Approval requirement '{requirement.Description}' must require at least one approval.");
- if (requiredApprovals > targetCount)
+ if (config.RequiredApprovals > targetCount)
throw new InvalidMutationApprovalConfigurationException(
- $"Approval requirement '{requirement.Description}' requires {requiredApprovals} approval(s) but only defines {targetCount} target(s).");
+ $"Approval requirement '{requirement.Description}' requires {config.RequiredApprovals} approval(s) but only defines {targetCount} target(s).");
- if (targetCount > 1 && string.IsNullOrWhiteSpace(approvalGroupId))
- approvalGroupId = $"approval-group-{defaultStepOrder + 1}-{defaultStepOrder}";
+ var approvalGroupId = ResolveApprovalGroupId(config.ApprovalGroupId, targetCount, defaultStepOrder);
- var mapped = approvers
- .Select(approverId => new MutationApprovalRequirement
- {
- Type = requirement.Type,
- Description = requirement.Description,
- ApproverId = approverId,
- ApproverName = approverName,
- StepOrder = stepOrder,
- ApprovalGroupId = approvalGroupId,
- RequiredApprovals = requiredApprovals,
- ExpiresAt = expiresAt,
- Metadata = new Dictionary
- {
- ["RequirementDescription"] = requirement.Description,
- ["RequirementReason"] = reason ?? string.Empty
- }
- })
+ return config.Approvers
+ .Select(approverId => CreateRequirement(
+ requirement,
+ config.ApproverName,
+ config.Reason,
+ config.StepOrder,
+ approvalGroupId,
+ config.RequiredApprovals,
+ config.ExpiresAt,
+ approverId: approverId))
+ .Concat(CreateOptionalRequirements(
+ requirement,
+ config.ApproverName,
+ config.Reason,
+ config.StepOrder,
+ approvalGroupId,
+ config.RequiredApprovals,
+ config.ExpiresAt,
+ config.ApproverRole,
+ config.ApproverGroup))
.ToList();
+ }
- if (!string.IsNullOrWhiteSpace(approverRole))
- {
- mapped.Add(new MutationApprovalRequirement
- {
- Type = requirement.Type,
- Description = requirement.Description,
- ApproverRole = approverRole,
- ApproverName = approverName,
- StepOrder = stepOrder,
- ApprovalGroupId = approvalGroupId,
- RequiredApprovals = requiredApprovals,
- ExpiresAt = expiresAt,
- Metadata = new Dictionary
- {
- ["RequirementDescription"] = requirement.Description,
- ["RequirementReason"] = reason ?? string.Empty
- }
- });
- }
+ private static ApprovalRequirementConfig ReadApprovalRequirementConfig(
+ PolicyRequirement requirement,
+ int defaultStepOrder)
+ => new(
+ StepOrder: ReadIntProperty(requirement.Data, "StepOrder") ?? defaultStepOrder + 1,
+ ApproverName: ReadStringProperty(requirement.Data, "ApproverName"),
+ Reason: ReadStringProperty(requirement.Data, "Reason"),
+ ApprovalGroupId: ReadStringProperty(requirement.Data, "ApprovalGroupId")
+ ?? ReadStringProperty(requirement.Data, "GroupId"),
+ ApproverRole: ReadStringProperty(requirement.Data, "ApproverRole"),
+ ApproverGroup: ReadStringProperty(requirement.Data, "ApproverGroup"),
+ RequiredApprovals: ReadIntProperty(requirement.Data, "RequiredApprovals")
+ ?? ReadIntProperty(requirement.Data, "Quorum")
+ ?? 1,
+ ExpiresAt: ReadDateTimeOffsetProperty(requirement.Data, "ExpiresAt"),
+ Approvers: ReadApprovers(requirement.Data));
+
+ private static IReadOnlyList ReadApprovers(object? source)
+ {
+ var approvers = ReadStringSequenceProperty(source, "Approvers");
+ if (approvers.Count > 0)
+ return approvers;
+
+ var approver = ReadStringProperty(source, "Approver");
+ return string.IsNullOrWhiteSpace(approver) ? [] : [approver];
+ }
+
+ private static int CountTargets(ApprovalRequirementConfig config)
+ => config.Approvers.Count
+ + CountOptionalTarget(config.ApproverRole)
+ + CountOptionalTarget(config.ApproverGroup);
- if (!string.IsNullOrWhiteSpace(approverGroup))
+ private static int CountOptionalTarget(string? value)
+ => string.IsNullOrWhiteSpace(value) ? 0 : 1;
+
+ private static string? ResolveApprovalGroupId(
+ string? approvalGroupId,
+ int targetCount,
+ int defaultStepOrder)
+ => targetCount > 1 && string.IsNullOrWhiteSpace(approvalGroupId)
+ ? $"approval-group-{defaultStepOrder + 1}-{defaultStepOrder}"
+ : approvalGroupId;
+
+ private static IEnumerable CreateOptionalRequirements(
+ PolicyRequirement requirement,
+ string? approverName,
+ string? reason,
+ int stepOrder,
+ string? approvalGroupId,
+ int requiredApprovals,
+ DateTimeOffset? expiresAt,
+ string? approverRole,
+ string? approverGroup)
+ {
+ var targets = new[]
{
- mapped.Add(new MutationApprovalRequirement
- {
- Type = requirement.Type,
- Description = requirement.Description,
- ApproverGroup = approverGroup,
- ApproverName = approverName,
- StepOrder = stepOrder,
- ApprovalGroupId = approvalGroupId,
- RequiredApprovals = requiredApprovals,
- ExpiresAt = expiresAt,
- Metadata = new Dictionary
- {
- ["RequirementDescription"] = requirement.Description,
- ["RequirementReason"] = reason ?? string.Empty
- }
- });
- }
+ new { ApproverRole = approverRole, ApproverGroup = (string?)null },
+ new { ApproverRole = (string?)null, ApproverGroup = approverGroup }
+ };
- return mapped;
+ return targets
+ .Where(target =>
+ !string.IsNullOrWhiteSpace(target.ApproverRole) ||
+ !string.IsNullOrWhiteSpace(target.ApproverGroup))
+ .Select(target => CreateRequirement(
+ requirement,
+ approverName,
+ reason,
+ stepOrder,
+ approvalGroupId,
+ requiredApprovals,
+ expiresAt,
+ approverRole: target.ApproverRole,
+ approverGroup: target.ApproverGroup));
}
+ private static MutationApprovalRequirement CreateRequirement(
+ PolicyRequirement requirement,
+ string? approverName,
+ string? reason,
+ int stepOrder,
+ string? approvalGroupId,
+ int requiredApprovals,
+ DateTimeOffset? expiresAt,
+ string approverId = "",
+ string? approverRole = null,
+ string? approverGroup = null)
+ => new()
+ {
+ Type = requirement.Type,
+ Description = requirement.Description,
+ ApproverId = approverId,
+ ApproverRole = approverRole,
+ ApproverGroup = approverGroup,
+ ApproverName = approverName,
+ StepOrder = stepOrder,
+ ApprovalGroupId = approvalGroupId,
+ RequiredApprovals = requiredApprovals,
+ ExpiresAt = expiresAt,
+ Metadata = new Dictionary
+ {
+ ["RequirementDescription"] = requirement.Description,
+ ["RequirementReason"] = reason ?? string.Empty
+ }
+ };
+
private static string? ReadStringProperty(object? source, string propertyName)
{
- if (source is null)
- return null;
-
- var property = source.GetType().GetProperty(propertyName);
- var value = property?.GetValue(source);
+ var value = ReadPropertyValue(source, propertyName);
return value as string;
}
private static int? ReadIntProperty(object? source, string propertyName)
{
- if (source is null)
- return null;
-
- var property = source.GetType().GetProperty(propertyName);
- var value = property?.GetValue(source);
+ var value = ReadPropertyValue(source, propertyName);
return value switch
{
int intValue => intValue,
long longValue => checked((int)longValue),
short shortValue => shortValue,
+ decimal decimalValue => decimal.ToInt32(decimalValue),
_ => null
};
}
private static DateTimeOffset? ReadDateTimeOffsetProperty(object? source, string propertyName)
{
- if (source is null)
- return null;
-
- var property = source.GetType().GetProperty(propertyName);
- var value = property?.GetValue(source);
+ var value = ReadPropertyValue(source, propertyName);
return value switch
{
@@ -179,11 +224,7 @@ string text when DateTimeOffset.TryParse(text, out var parsed) => parsed,
private static List ReadStringSequenceProperty(object? source, string propertyName)
{
- if (source is null)
- return [];
-
- var property = source.GetType().GetProperty(propertyName);
- var value = property?.GetValue(source);
+ var value = ReadPropertyValue(source, propertyName);
return value switch
{
@@ -195,4 +236,24 @@ private static List ReadStringSequenceProperty(object? source, string pr
_ => []
};
}
+
+ private static object? ReadPropertyValue(object? source, string propertyName)
+ {
+ if (source is null)
+ return null;
+
+ if (source is IReadOnlyDictionary readOnlyDictionary &&
+ readOnlyDictionary.TryGetValue(propertyName, out var readOnlyValue))
+ return readOnlyValue;
+
+ if (source is IDictionary dictionary &&
+ dictionary.TryGetValue(propertyName, out var dictionaryValue))
+ return dictionaryValue;
+
+ if (source is IDictionary nonGenericDictionary && nonGenericDictionary.Contains(propertyName))
+ return nonGenericDictionary[propertyName];
+
+ var property = source.GetType().GetProperty(propertyName);
+ return property?.GetValue(source);
+ }
}
diff --git a/src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs b/src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs
new file mode 100644
index 0000000..f8c327d
--- /dev/null
+++ b/src/Governance/Abstractions/Queries/Contracts/IMutationRequestQueryStore.cs
@@ -0,0 +1,54 @@
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
+using ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
+
+namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts;
+
+///
+/// Query oriented access to governed mutation requests.
+///
+public interface IMutationRequestQueryStore
+{
+ ///
+ /// Queries governed requests using the supplied criteria.
+ ///
+ Task> QueryAsync(
+ MutationRequestQuery query,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns pending requests, optionally narrowed by additional criteria.
+ ///
+ Task> GetPendingRequestsAsync(
+ MutationRequestQuery? query = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns the pending approval queue, optionally narrowed by additional criteria.
+ ///
+ Task> GetPendingApprovalQueueAsync(
+ MutationRequestQuery? query = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns recent approval driven requests, optionally narrowed by additional criteria.
+ ///
+ Task> GetRecentApprovalsAsync(
+ MutationRequestQuery? query = null,
+ int? take = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns approval oriented projections for governed requests.
+ ///
+ Task> GetPendingApprovalsAsync(
+ MutationApprovalQuery? query = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns recent decision oriented projections across governed requests.
+ ///
+ Task> GetRecentDecisionsAsync(
+ MutationRequestDecisionQuery? query = null,
+ int? take = null,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs b/src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs
new file mode 100644
index 0000000..9b7a981
--- /dev/null
+++ b/src/Governance/Abstractions/Queries/Model/MutationApprovalQuery.cs
@@ -0,0 +1,71 @@
+using ModularityKit.Mutator.Governance.Abstractions.Approval.Model;
+using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
+
+namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
+
+///
+/// Defines storage-agnostic filters for approval-oriented governance queries.
+///
+public sealed record MutationApprovalQuery
+{
+ ///
+ /// Request-level filters applied before approval requirements are projected.
+ ///
+ public MutationRequestQuery RequestQuery { get; init; } = new();
+
+ ///
+ /// Request categories to include.
+ ///
+ public IReadOnlySet Categories { get; init; } = new HashSet();
+
+ ///
+ /// Allowed approver identifiers.
+ ///
+ public IReadOnlySet ApproverIds { get; init; } = new HashSet();
+
+ ///
+ /// Allowed approver roles.
+ ///
+ public IReadOnlySet ApproverRoles { get; init; } = new HashSet();
+
+ ///
+ /// Allowed approver groups.
+ ///
+ public IReadOnlySet ApproverGroups { get; init; } = new HashSet();
+
+ ///
+ /// Allowed approval requirement statuses.
+ ///
+ public IReadOnlySet ApprovalStatuses { get; init; }
+ = new HashSet();
+
+ ///
+ /// Allowed pending reasons for the parent request.
+ ///
+ public IReadOnlySet PendingReasons { get; init; } = new HashSet();
+
+ ///
+ /// Allowed request statuses for the parent request.
+ ///
+ public IReadOnlySet RequestStatuses { get; init; } = new HashSet();
+
+ ///
+ /// Creates a query for pending approval work.
+ ///
+ public static MutationApprovalQuery Pending()
+ => new()
+ {
+ ApprovalStatuses = new HashSet
+ {
+ MutationApprovalRequirementStatus.Pending
+ },
+ RequestStatuses = new HashSet
+ {
+ MutationRequestStatus.Pending
+ },
+ PendingReasons = new HashSet
+ {
+ PendingMutationReason.Approval
+ }
+ };
+}
diff --git a/src/Governance/Abstractions/Queries/Model/MutationApprovalQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/MutationApprovalQueryEvaluator.cs
new file mode 100644
index 0000000..af60980
--- /dev/null
+++ b/src/Governance/Abstractions/Queries/Model/MutationApprovalQueryEvaluator.cs
@@ -0,0 +1,50 @@
+using ModularityKit.Mutator.Governance.Abstractions.Approval.Model;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
+
+namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
+
+///
+/// Evaluates approval oriented query criteria against governed mutation requests.
+///
+public static class MutationApprovalQueryEvaluator
+{
+ public static bool Matches(MutationRequest request, MutationApprovalRequirement approval, MutationApprovalQuery query)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(approval);
+ ArgumentNullException.ThrowIfNull(query);
+
+ return MutationRequestQueryEvaluator.Matches(request, query.RequestQuery) &&
+ MatchesCategory(request, query) &&
+ MatchesApproverId(approval, query) &&
+ MatchesApproverRole(approval, query) &&
+ MatchesApproverGroup(approval, query) &&
+ MatchesApprovalStatus(approval, query) &&
+ MatchesPendingReason(request, query) &&
+ MatchesRequestStatus(request, query);
+ }
+
+ private static bool MatchesCategory(MutationRequest request, MutationApprovalQuery query)
+ => query.Categories.Count == 0 || query.Categories.Contains(request.Intent.Category);
+
+ private static bool MatchesApproverId(MutationApprovalRequirement approval, MutationApprovalQuery query)
+ => query.ApproverIds.Count == 0 || query.ApproverIds.Contains(approval.ApproverId);
+
+ private static bool MatchesApproverRole(MutationApprovalRequirement approval, MutationApprovalQuery query)
+ => query.ApproverRoles.Count == 0 ||
+ (approval.ApproverRole is not null && query.ApproverRoles.Contains(approval.ApproverRole));
+
+ private static bool MatchesApproverGroup(MutationApprovalRequirement approval, MutationApprovalQuery query)
+ => query.ApproverGroups.Count == 0 ||
+ (approval.ApproverGroup is not null && query.ApproverGroups.Contains(approval.ApproverGroup));
+
+ private static bool MatchesApprovalStatus(MutationApprovalRequirement approval, MutationApprovalQuery query)
+ => query.ApprovalStatuses.Count == 0 || query.ApprovalStatuses.Contains(approval.Status);
+
+ private static bool MatchesPendingReason(MutationRequest request, MutationApprovalQuery query)
+ => query.PendingReasons.Count == 0 ||
+ (request.PendingReason is not null && query.PendingReasons.Contains(request.PendingReason.Value));
+
+ private static bool MatchesRequestStatus(MutationRequest request, MutationApprovalQuery query)
+ => query.RequestStatuses.Count == 0 || query.RequestStatuses.Contains(request.Status);
+}
diff --git a/src/Governance/Abstractions/Queries/Model/MutationApprovalView.cs b/src/Governance/Abstractions/Queries/Model/MutationApprovalView.cs
new file mode 100644
index 0000000..90c32bd
--- /dev/null
+++ b/src/Governance/Abstractions/Queries/Model/MutationApprovalView.cs
@@ -0,0 +1,20 @@
+using ModularityKit.Mutator.Governance.Abstractions.Approval.Model;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
+
+namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
+
+///
+/// Represents one approval oriented projection from governed mutation request.
+///
+public sealed record MutationApprovalView
+{
+ ///
+ /// Parent mutation request.
+ ///
+ public MutationRequest Request { get; init; } = null!;
+
+ ///
+ /// Approval requirement projected from the parent request.
+ ///
+ public MutationApprovalRequirement Approval { get; init; } = null!;
+}
diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs b/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs
new file mode 100644
index 0000000..14bcd7d
--- /dev/null
+++ b/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQuery.cs
@@ -0,0 +1,77 @@
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions;
+
+namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
+
+///
+/// Defines storage agnostic filters for decision oriented governance queries.
+///
+public sealed record MutationRequestDecisionQuery
+{
+ ///
+ /// Request level filters applied before decisions are projected.
+ ///
+ public MutationRequestQuery RequestQuery { get; init; } = new();
+
+ ///
+ /// Decision categories to include.
+ ///
+ public IReadOnlySet Categories { get; init; }
+ = new HashSet();
+
+ ///
+ /// Decision codes to include.
+ ///
+ public IReadOnlySet Codes { get; init; } = new HashSet();
+
+ ///
+ /// Decision actor identifiers to include.
+ ///
+ public IReadOnlySet ActorIds { get; init; } = new HashSet();
+
+ ///
+ /// Decision actor names to include.
+ ///
+ public IReadOnlySet ActorNames { get; init; } = new HashSet();
+
+ ///
+ /// Inclusive lower bound for decision timestamps.
+ ///
+ public DateTimeOffset? From { get; init; }
+
+ ///
+ /// Inclusive upper bound for decision timestamps.
+ ///
+ public DateTimeOffset? To { get; init; }
+
+ ///
+ /// Creates query for recent version-resolution decisions.
+ ///
+ public static MutationRequestDecisionQuery RecentVersionResolutions()
+ => new()
+ {
+ Categories = new HashSet
+ {
+ MutationRequestDecisionCategory.VersionResolution
+ }
+ };
+
+ ///
+ /// Creates query for recent execution outcomes.
+ ///
+ public static MutationRequestDecisionQuery RecentExecutionOutcomes()
+ => new()
+ {
+ Categories = new HashSet
+ {
+ MutationRequestDecisionCategory.Lifecycle
+ },
+ Codes = new HashSet
+ {
+ MutationRequestLifecycleDecisionType.Executed.ToString(),
+ MutationRequestLifecycleDecisionType.Rejected.ToString(),
+ MutationRequestLifecycleDecisionType.Canceled.ToString(),
+ MutationRequestLifecycleDecisionType.Expired.ToString(),
+ MutationRequestLifecycleDecisionType.Superseded.ToString()
+ }
+ };
+}
diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQueryEvaluator.cs b/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQueryEvaluator.cs
new file mode 100644
index 0000000..1a6313a
--- /dev/null
+++ b/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionQueryEvaluator.cs
@@ -0,0 +1,62 @@
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
+
+namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
+
+///
+/// Evaluates decision oriented query criteria against governed mutation requests.
+///
+public static class MutationRequestDecisionQueryEvaluator
+{
+ public static bool Matches(
+ MutationRequest request,
+ MutationRequestDecision decision,
+ MutationRequestDecisionQuery query)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(decision);
+ ArgumentNullException.ThrowIfNull(query);
+
+ return MutationRequestQueryEvaluator.Matches(request, query.RequestQuery) &&
+ MatchesCategory(decision, query) &&
+ MatchesCode(decision, query) &&
+ MatchesActorId(decision, query) &&
+ MatchesActorName(decision, query) &&
+ MatchesTimestamp(decision, query);
+ }
+
+ private static bool MatchesCategory(
+ MutationRequestDecision decision,
+ MutationRequestDecisionQuery query)
+ => query.Categories.Count == 0 || query.Categories.Contains(decision.Type.Category);
+
+ private static bool MatchesCode(
+ MutationRequestDecision decision,
+ MutationRequestDecisionQuery query)
+ => query.Codes.Count == 0 || query.Codes.Contains(decision.Type.Code);
+
+ private static bool MatchesActorId(
+ MutationRequestDecision decision,
+ MutationRequestDecisionQuery query)
+ => query.ActorIds.Count == 0 ||
+ (decision.Context.ActorId is not null && query.ActorIds.Contains(decision.Context.ActorId));
+
+ private static bool MatchesActorName(
+ MutationRequestDecision decision,
+ MutationRequestDecisionQuery query)
+ => query.ActorNames.Count == 0 ||
+ (decision.Context.ActorName is not null && query.ActorNames.Contains(decision.Context.ActorName));
+
+ private static bool MatchesTimestamp(
+ MutationRequestDecision decision,
+ MutationRequestDecisionQuery query)
+ {
+ if (query.From.HasValue && decision.Timestamp < query.From.Value)
+ return false;
+
+ if (query.To.HasValue && decision.Timestamp > query.To.Value)
+ return false;
+
+ return true;
+ }
+}
diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionView.cs b/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionView.cs
new file mode 100644
index 0000000..6ef3212
--- /dev/null
+++ b/src/Governance/Abstractions/Queries/Model/MutationRequestDecisionView.cs
@@ -0,0 +1,20 @@
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
+
+namespace ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
+
+///
+/// Represents one decision-oriented projection from a governed mutation request.
+///
+public sealed record MutationRequestDecisionView
+{
+ ///
+ /// Parent mutation request.
+ ///
+ public MutationRequest Request { get; init; } = null!;
+
+ ///
+ /// Decision projected from the parent request.
+ ///
+ public MutationRequestDecision Decision { get; init; } = null!;
+}
diff --git a/src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs b/src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs
new file mode 100644
index 0000000..1bc0f49
--- /dev/null
+++ b/src/Governance/Abstractions/Queries/Model/MutationRequestQuery.cs
@@ -0,0 +1,163 @@
+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
new file mode 100644
index 0000000..379dfb4
--- /dev/null
+++ b/src/Governance/Abstractions/Queries/Model/MutationRequestQueryEvaluator.cs
@@ -0,0 +1,157 @@
+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/README.md b/src/Governance/README.md
index 6b15921..9cefb77 100644
--- a/src/Governance/README.md
+++ b/src/Governance/README.md
@@ -60,6 +60,15 @@ Console.WriteLine($"{persisted.RequestId} -> {persisted.Status}");
- `MutationRequestApprovalWorkflowManager`
- `MutationApprovalRequirement`
+### Queries
+
+- `IMutationRequestQueryStore`
+- `MutationRequestQuery`
+- `MutationApprovalQuery`
+- `MutationRequestDecisionQuery`
+- `MutationApprovalView`
+- `MutationRequestDecisionView`
+
### Resolution
- `IMutationRequestVersionResolver`
@@ -94,6 +103,8 @@ Runnable examples live under [`Examples/Governance`](../../Examples/Governance):
- [`ApprovalWorkflow`](../../Examples/Governance/ApprovalWorkflow/README.md)
- [`VersionedResolution`](../../Examples/Governance/VersionedResolution/README.md)
- [`GovernedExecution`](../../Examples/Governance/GovernedExecution/README.md)
+- [`Queries`](../../Examples/Governance/Queries/README.md)
+- [`RedisQueries`](../../Examples/Governance/RedisQueries/README.md)
## Relationship to the core package
@@ -101,6 +112,8 @@ Runnable examples live under [`Examples/Governance`](../../Examples/Governance):
`ModularityKit.Mutator.Governance` owns the request lifecycle around that execution: approvals, pending states, request storage, stale-version resolution, and terminal governance decisions.
+It also owns the query-oriented read side for governed requests, approval work, and decision history.
+
## Current scope
Included today:
diff --git a/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs
index dec9c9e..f513697 100644
--- a/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs
+++ b/src/Governance/Runtime/Storage/InMemoryMutationRequestStore.cs
@@ -1,5 +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.Lifecycle.Model;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Decisions;
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
using ModularityKit.Mutator.Governance.Abstractions.Storage;
@@ -9,7 +12,7 @@ namespace ModularityKit.Mutator.Governance.Runtime.Storage;
/// In-memory store for governance mutation requests.
/// Suitable for examples, tests, and local development.
///
-public sealed class InMemoryMutationRequestStore : IMutationRequestStore
+public sealed class InMemoryMutationRequestStore : IMutationRequestStore, IMutationRequestQueryStore
{
private readonly Dictionary _requests = new();
private readonly Lock _lock = new();
@@ -121,4 +124,137 @@ public Task> GetPendingByStateId(
return Task.FromResult>(requests);
}
}
+
+ public Task> QueryAsync(
+ MutationRequestQuery query,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+
+ lock (_lock)
+ {
+ var requests = _requests.Values
+ .Where(request => MutationRequestQueryEvaluator.Matches(request, query))
+ .OrderBy(request => request.CreatedAt)
+ .ThenBy(request => request.RequestId)
+ .ToList();
+
+ return Task.FromResult>(requests);
+ }
+ }
+
+ public Task> GetPendingRequestsAsync(
+ MutationRequestQuery? query = null,
+ CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ var requests = _requests.Values
+ .Where(request => MutationRequestQueryEvaluator.Matches(request, query ?? new MutationRequestQuery()) &&
+ request.Status == MutationRequestStatus.Pending)
+ .OrderBy(request => request.CreatedAt)
+ .ThenBy(request => request.RequestId)
+ .ToList();
+
+ return Task.FromResult>(requests);
+ }
+ }
+
+ public Task> GetPendingApprovalQueueAsync(
+ MutationRequestQuery? query = null,
+ CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ var requests = _requests.Values
+ .Where(request =>
+ MutationRequestQueryEvaluator.Matches(request, query ?? new MutationRequestQuery()) &&
+ request.Status == MutationRequestStatus.Pending &&
+ request.PendingReason == PendingMutationReason.Approval)
+ .OrderBy(request => request.CreatedAt)
+ .ThenBy(request => request.RequestId)
+ .ToList();
+
+ return Task.FromResult>(requests);
+ }
+ }
+
+ public Task> GetRecentApprovalsAsync(
+ MutationRequestQuery? query = null,
+ int? take = null,
+ CancellationToken cancellationToken = default)
+ {
+ var effectiveQuery = query ?? MutationRequestQuery.RecentApprovals();
+
+ lock (_lock)
+ {
+ var requests = _requests.Values
+ .Where(request => MutationRequestQueryEvaluator.Matches(request, effectiveQuery) &&
+ MutationRequestQueryEvaluator.HasApprovalActivity(request))
+ .OrderByDescending(MutationRequestQueryEvaluator.GetRecentApprovalTimestamp)
+ .ThenByDescending(request => request.UpdatedAt)
+ .ThenBy(request => request.RequestId)
+ .ToList();
+
+ if (take.HasValue && take.Value >= 0)
+ requests = requests.Take(take.Value).ToList();
+
+ return Task.FromResult>(requests);
+ }
+ }
+
+ public Task> GetPendingApprovalsAsync(
+ MutationApprovalQuery? query = null,
+ CancellationToken cancellationToken = default)
+ {
+ var effectiveQuery = query ?? MutationApprovalQuery.Pending();
+
+ lock (_lock)
+ {
+ var approvals = _requests.Values
+ .SelectMany(request => request.ApprovalRequirements.Select(approval => new MutationApprovalView
+ {
+ Request = request,
+ Approval = approval
+ }))
+ .Where(view => MutationApprovalQueryEvaluator.Matches(view.Request, view.Approval, effectiveQuery))
+ .OrderBy(view => view.Request.CreatedAt)
+ .ThenBy(view => view.Request.RequestId)
+ .ThenBy(view => view.Approval.StepOrder)
+ .ThenBy(view => view.Approval.ApprovalId)
+ .ToList();
+
+ return Task.FromResult>(approvals);
+ }
+ }
+
+ public Task> GetRecentDecisionsAsync(
+ MutationRequestDecisionQuery? query = null,
+ int? take = null,
+ CancellationToken cancellationToken = default)
+ {
+ var effectiveQuery = query ?? new MutationRequestDecisionQuery();
+
+ lock (_lock)
+ {
+ IEnumerable decisions = _requests.Values
+ .SelectMany(request => request.Decisions.Select(decision => new MutationRequestDecisionView
+ {
+ Request = request,
+ Decision = decision
+ }))
+ .Where(view => MutationRequestDecisionQueryEvaluator.Matches(
+ view.Request,
+ view.Decision,
+ effectiveQuery))
+ .OrderByDescending(view => view.Decision.Timestamp)
+ .ThenByDescending(view => view.Request.UpdatedAt)
+ .ThenBy(view => view.Request.RequestId);
+
+ if (take.HasValue && take.Value >= 0)
+ decisions = decisions.Take(take.Value);
+
+ return Task.FromResult>(decisions.ToList());
+ }
+ }
}
diff --git a/src/ModularityKit.Mutator.csproj b/src/ModularityKit.Mutator.csproj
index 1ed350b..35692b4 100644
--- a/src/ModularityKit.Mutator.csproj
+++ b/src/ModularityKit.Mutator.csproj
@@ -22,6 +22,7 @@
+
diff --git a/src/Redis/Configuration/RedisMutationRequestStoreOptions.cs b/src/Redis/Configuration/RedisMutationRequestStoreOptions.cs
new file mode 100644
index 0000000..c672cc7
--- /dev/null
+++ b/src/Redis/Configuration/RedisMutationRequestStoreOptions.cs
@@ -0,0 +1,12 @@
+namespace ModularityKit.Mutator.Governance.Redis.Configuration;
+
+///
+/// Configuration for Redis-backed governance request storage.
+///
+public sealed class RedisMutationRequestStoreOptions
+{
+ ///
+ /// Key prefix used by the provider.
+ ///
+ public string KeyPrefix { get; set; } = "modularitykit:governance";
+}
diff --git a/src/Redis/Keys/RedisMutationRequestKeyspace.cs b/src/Redis/Keys/RedisMutationRequestKeyspace.cs
new file mode 100644
index 0000000..d06448c
--- /dev/null
+++ b/src/Redis/Keys/RedisMutationRequestKeyspace.cs
@@ -0,0 +1,104 @@
+using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
+using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
+using ModularityKit.Mutator.Governance.Redis.Configuration;
+using StackExchange.Redis;
+
+namespace ModularityKit.Mutator.Governance.Redis.Keys;
+
+///
+/// Centralizes Redis key naming for governed mutation requests.
+///
+public sealed class RedisMutationRequestKeyspace
+{
+ private readonly string _keyPrefix;
+
+ ///
+ /// Initializes a new keyspace instance for the configured Redis prefix.
+ ///
+ /// The Redis provider options.
+ public RedisMutationRequestKeyspace(RedisMutationRequestStoreOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (string.IsNullOrWhiteSpace(options.KeyPrefix))
+ throw new ArgumentException("Redis key prefix cannot be empty.", nameof(options));
+
+ _keyPrefix = options.KeyPrefix;
+ }
+
+ ///
+ /// Gets the Redis key that stores all known request identifiers.
+ ///
+ /// The Redis key for the global request-id set.
+ public RedisKey RequestIds() => $"{_keyPrefix}:requests:ids";
+
+ ///
+ /// Gets the Redis key that stores the serialized document for a request.
+ ///
+ /// The request identifier.
+ /// The Redis key for the request document.
+ public RedisKey RequestData(string requestId) => $"{_keyPrefix}:requests:{requestId}:data";
+
+ ///
+ /// Gets the Redis key that stores the optimistic-concurrency revision for a request.
+ ///
+ /// The request identifier.
+ /// The Redis key for the request revision.
+ public RedisKey RequestRevision(string requestId) => $"{_keyPrefix}:requests:{requestId}:revision";
+
+ ///
+ /// Gets the Redis key for requests grouped by state identifier.
+ ///
+ /// The state identifier.
+ /// The Redis key for requests targeting the supplied state.
+ public RedisKey RequestsByStateId(string stateId) => $"{_keyPrefix}:states:{stateId}:requests";
+
+ ///
+ /// Gets the Redis key for requests grouped by governance status.
+ ///
+ /// The request status.
+ /// The Redis key for requests in the supplied status.
+ public RedisKey RequestsByStatus(MutationRequestStatus status)
+ => $"{_keyPrefix}:status:{status.ToString().ToLowerInvariant()}:requests";
+
+ ///
+ /// Gets the Redis key for all pending requests.
+ ///
+ /// The Redis key for the global pending-request set.
+ public RedisKey PendingRequestIds() => $"{_keyPrefix}:pending:requests";
+
+ ///
+ /// Gets the Redis key for pending requests grouped by pending reason.
+ ///
+ /// The pending reason.
+ /// The Redis key for the pending-request set of the supplied reason.
+ public RedisKey PendingRequestIds(PendingMutationReason reason)
+ => $"{_keyPrefix}:pending:{reason.ToString().ToLowerInvariant()}:requests";
+
+ ///
+ /// Enumerates the secondary-index keys that should contain the supplied request.
+ ///
+ /// The request to index.
+ /// The Redis keys representing all indexes for the request.
+ internal IReadOnlyList EnumerateIndexes(MutationRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var keys = new List
+ {
+ RequestIds(),
+ RequestsByStateId(request.StateId),
+ RequestsByStatus(request.Status)
+ };
+
+ if (request.Status == MutationRequestStatus.Pending)
+ {
+ keys.Add(PendingRequestIds());
+
+ if (request.PendingReason.HasValue)
+ keys.Add(PendingRequestIds(request.PendingReason.Value));
+ }
+
+ return keys;
+ }
+}
diff --git a/src/Redis/ModularityKit.Mutator.Governance.Redis.csproj b/src/Redis/ModularityKit.Mutator.Governance.Redis.csproj
new file mode 100644
index 0000000..0a9feb4
--- /dev/null
+++ b/src/Redis/ModularityKit.Mutator.Governance.Redis.csproj
@@ -0,0 +1,38 @@
+
+
+
+ net10.0
+ enable
+ enable
+ ModularityKit.Mutator.Governance.Redis
+ 0.1.0
+ ModularityKit
+ ModularityKit
+ Redis provider for ModularityKit.Mutator.Governance.
+ https://github.com/ModularityKit/ModularityKit.Mutator
+ https://github.com/ModularityKit/ModularityKit.Mutator.git
+ git
+ MIT
+ governance;redis;requests;query;provider
+ README.md
+ modularitykit-mutator-governance-redis-128.png
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Redis/Properties/AssemblyInfo.cs b/src/Redis/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..b24544a
--- /dev/null
+++ b/src/Redis/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("ModularityKit.Mutator.Governance.Redis.Tests")]
diff --git a/src/Redis/README.md b/src/Redis/README.md
new file mode 100644
index 0000000..93c60c3
--- /dev/null
+++ b/src/Redis/README.md
@@ -0,0 +1,54 @@
+
+
+## Package structure
+
+- `Configuration` for provider options
+- `DependencyInjection` for service registration
+- `Keys` for Redis key conventions
+- `Storage` for the public store facade
+- `Storage/Candidates` for Redis index candidate selection
+- `Storage/Candidates/Models` for candidate plan models
+- `Storage/Candidates/Planning` for candidate plan construction
+- `Storage/Candidates/Execution` for candidate plan execution
+- `Storage/Documents` for request document loading
+- `Storage/Documents/Keys` for Redis document key creation
+- `Storage/Documents/Payloads` for bulk payload reads
+- `Storage/Documents/Materialization` for request document deserialization and ordering
+- `Storage/Documents/Reading` for document read orchestration
+- `Storage/Identifiers` for Redis set and id reads
+- `Storage/Identifiers/Models` for identifier set operation models
+- `Storage/Identifiers/Loading` for Redis id set loading and normalization
+- `Storage/Persistence` for write-side request persistence
+- `Storage/Persistence/Models` for persistence record models
+- `Storage/Persistence/Reading` for single request persistence reads
+- `Storage/Persistence/Writing` for write transactions, payload creation, and index maintenance
+- `Storage/Queries` for query orchestration and result materialization
+- `Storage/Queries/Reading` for request query orchestration
+- `Storage/Queries/Materialization` for query result shaping
+- `Serialization` for request payload handling
+- `Serialization/Converters` for custom JSON converters
+
+## Usage
+
+```csharp
+using Microsoft.Extensions.DependencyInjection;
+using ModularityKit.Mutator.Governance.Redis;
+using StackExchange.Redis;
+
+var services = new ServiceCollection();
+var multiplexer = await ConnectionMultiplexer.ConnectAsync("localhost:6379");
+
+services.AddRedisGovernanceStore(
+ multiplexer,
+ options => options.KeyPrefix = "modularitykit:governance");
+```
+
+## Current query strategy
+
+The provider uses Redis indexes first and then applies the storage-agnostic governance query evaluator in memory for the final filter pass.
+
+Today this means:
+
+- point reads and optimistic concurrency stay fully Redis-backed
+- common queue views are narrowed by Redis set membership
+- broad ad hoc filters still finish in memory after candidate selection
diff --git a/src/Redis/RedisGovernanceServiceCollectionExtensions.cs b/src/Redis/RedisGovernanceServiceCollectionExtensions.cs
new file mode 100644
index 0000000..10b3c49
--- /dev/null
+++ b/src/Redis/RedisGovernanceServiceCollectionExtensions.cs
@@ -0,0 +1,80 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts;
+using ModularityKit.Mutator.Governance.Abstractions.Storage;
+using ModularityKit.Mutator.Governance.Redis.Configuration;
+using ModularityKit.Mutator.Governance.Redis.Keys;
+using ModularityKit.Mutator.Governance.Redis.Storage;
+using ModularityKit.Mutator.Governance.Redis.Storage.Candidates;
+using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Execution;
+using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Planning;
+using ModularityKit.Mutator.Governance.Redis.Storage.Documents;
+using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Keys;
+using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Payloads;
+using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Reading;
+using ModularityKit.Mutator.Governance.Redis.Storage.Identifiers;
+using ModularityKit.Mutator.Governance.Redis.Storage.Identifiers.Loading;
+using ModularityKit.Mutator.Governance.Redis.Storage.Persistence;
+using ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Reading;
+using ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Writing;
+using ModularityKit.Mutator.Governance.Redis.Storage.Queries;
+using ModularityKit.Mutator.Governance.Redis.Storage.Queries.Reading;
+using StackExchange.Redis;
+
+namespace ModularityKit.Mutator.Governance.Redis;
+
+///
+/// Dependency injection registration for the Redis governance provider.
+///
+public static class RedisGovernanceServiceCollectionExtensions
+{
+ ///
+ /// Registers Redis-backed governance request storage and query services.
+ ///
+ /// The service collection to configure.
+ /// The Redis connection multiplexer used by the provider.
+ /// An optional callback for configuring provider options.
+ /// The same service collection for chaining.
+ public static IServiceCollection AddRedisGovernanceStore(
+ this IServiceCollection services,
+ IConnectionMultiplexer connectionMultiplexer,
+ Action? configure = null)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(connectionMultiplexer);
+
+ var options = new RedisMutationRequestStoreOptions();
+ configure?.Invoke(options);
+
+ services.AddSingleton(connectionMultiplexer);
+ services.AddSingleton(Microsoft.Extensions.Options.Options.Create(options));
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton(sp =>
+ {
+ var resolvedOptions = sp.GetRequiredService>();
+ return new RedisMutationRequestKeyspace(resolvedOptions.Value);
+ });
+ services.TryAddSingleton(sp => sp.GetRequiredService().GetDatabase());
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton(sp => new RedisMutationRequestStore(
+ sp.GetRequiredService(),
+ sp.GetRequiredService()));
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+
+ return services;
+ }
+}
diff --git a/src/Redis/Serialization/Converters/InferredObjectJsonConverter.cs b/src/Redis/Serialization/Converters/InferredObjectJsonConverter.cs
new file mode 100644
index 0000000..ce554b8
--- /dev/null
+++ b/src/Redis/Serialization/Converters/InferredObjectJsonConverter.cs
@@ -0,0 +1,97 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ModularityKit.Mutator.Governance.Redis.Serialization.Converters;
+
+///
+/// Deserializes flexible metadata values into inferred CLR object graphs.
+///
+internal sealed class InferredObjectJsonConverter : JsonConverter