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 @@ +![ModularityKit.Mutator.Governance.Redis overview](../../assets/governance/providers/mutator-governance-redis-overview.png) + +## 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 +{ + /// + /// Reads a JSON value into an inferred CLR object graph. + /// + /// The JSON reader. + /// The target type. + /// The serializer options. + /// The inferred CLR value. + public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => ReadValue(ref reader); + + /// + /// Writes an inferred CLR value back to JSON. + /// + /// The JSON writer. + /// The CLR value to write. + /// The serializer options. + public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } + + private static object? ReadValue(ref Utf8JsonReader reader) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + case JsonTokenType.Null: + return null; + case JsonTokenType.Number: + if (reader.TryGetInt64(out var longValue)) + return longValue; + + if (reader.TryGetDecimal(out var decimalValue)) + return decimalValue; + + return reader.GetDouble(); + case JsonTokenType.String: + return reader.GetString(); + case JsonTokenType.StartArray: + { + var list = new List(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + return list; + + list.Add(ReadValue(ref reader)); + } + + throw new JsonException("Unexpected end of JSON while reading array."); + } + case JsonTokenType.StartObject: + { + var dictionary = new Dictionary(StringComparer.Ordinal); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + return dictionary; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException($"Unexpected token '{reader.TokenType}' while reading object."); + + var propertyName = reader.GetString() ?? string.Empty; + + if (!reader.Read()) + throw new JsonException("Unexpected end of JSON after property name."); + + dictionary[propertyName] = ReadValue(ref reader); + } + + throw new JsonException("Unexpected end of JSON while reading object."); + } + default: + throw new JsonException($"Unsupported JSON token '{reader.TokenType}' for inferred object conversion."); + } + } +} diff --git a/src/Redis/Serialization/Converters/ReadOnlySetJsonConverterFactory.cs b/src/Redis/Serialization/Converters/ReadOnlySetJsonConverterFactory.cs new file mode 100644 index 0000000..3f8d648 --- /dev/null +++ b/src/Redis/Serialization/Converters/ReadOnlySetJsonConverterFactory.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModularityKit.Mutator.Governance.Redis.Serialization.Converters; + +/// +/// Creates converters for payload members. +/// +internal sealed class ReadOnlySetJsonConverterFactory : JsonConverterFactory +{ + /// + /// Determines whether the supplied type is a supported read-only set type. + /// + /// The type to inspect. + /// when the type is supported; otherwise . + public override bool CanConvert(Type typeToConvert) + => typeToConvert.IsGenericType && + typeToConvert.GetGenericTypeDefinition() == typeof(IReadOnlySet<>); + + /// + /// Creates a converter instance for the supplied read-only set type. + /// + /// The set type to convert. + /// The serializer options. + /// The created JSON converter. + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var itemType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(ReadOnlySetJsonConverter<>).MakeGenericType(itemType); + return (JsonConverter)Activator.CreateInstance(converterType)!; + } + + /// + /// Converts a concrete read-only set payload for a specific item type. + /// + /// The item type contained in the set. + private sealed class ReadOnlySetJsonConverter : JsonConverter> + { + /// + /// Reads a JSON array into a read-only set. + /// + /// The JSON reader. + /// The target type. + /// The serializer options. + /// The materialized read-only set. + public override IReadOnlySet Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var values = JsonSerializer.Deserialize>(ref reader, options); + return values ?? new HashSet(); + } + + /// + /// Writes a read-only set as a JSON array. + /// + /// The JSON writer. + /// The set value to write. + /// The serializer options. + public override void Write(Utf8JsonWriter writer, IReadOnlySet value, JsonSerializerOptions options) + => JsonSerializer.Serialize(writer, value.ToArray(), options); + } +} diff --git a/src/Redis/Serialization/RedisMutationRequestSerializer.cs b/src/Redis/Serialization/RedisMutationRequestSerializer.cs new file mode 100644 index 0000000..70410e0 --- /dev/null +++ b/src/Redis/Serialization/RedisMutationRequestSerializer.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Serialization.Converters; + +namespace ModularityKit.Mutator.Governance.Redis.Serialization; + +/// +/// Serializes governed mutation requests to and from Redis JSON payloads. +/// +internal static class RedisMutationRequestSerializer +{ + private static readonly JsonSerializerOptions SerializerOptions = CreateSerializerOptions(); + + /// + /// Serializes a governed mutation request into a Redis JSON payload. + /// + /// The request to serialize. + /// The serialized JSON payload. + public static string Serialize(MutationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return JsonSerializer.Serialize(request, SerializerOptions); + } + + /// + /// Deserializes a Redis JSON payload into a governed mutation request. + /// + /// The JSON payload to deserialize. + /// The deserialized mutation request. + public static MutationRequest Deserialize(string json) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + + var request = JsonSerializer.Deserialize(json, SerializerOptions); + if (request is null) + throw new InvalidOperationException("Redis mutation request payload deserialized to null."); + + return request; + } + + private static JsonSerializerOptions CreateSerializerOptions() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + options.Converters.Add(new InferredObjectJsonConverter()); + options.Converters.Add(new ReadOnlySetJsonConverterFactory()); + return options; + } +} diff --git a/src/Redis/Storage/Candidates/Execution/RedisMutationRequestCandidateExecutor.cs b/src/Redis/Storage/Candidates/Execution/RedisMutationRequestCandidateExecutor.cs new file mode 100644 index 0000000..c2add76 --- /dev/null +++ b/src/Redis/Storage/Candidates/Execution/RedisMutationRequestCandidateExecutor.cs @@ -0,0 +1,41 @@ +using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Models; +using ModularityKit.Mutator.Governance.Redis.Storage.Identifiers.Loading; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Execution; + +/// +/// Executes candidate id lookup plans against Redis set data. +/// +internal sealed class RedisMutationRequestCandidateExecutor(RedisMutationRequestIdSetReader idSetReader) +{ + private readonly RedisMutationRequestIdSetReader _idSetReader = idSetReader ?? throw new ArgumentNullException(nameof(idSetReader)); + + /// + /// Executes a candidate plan and returns the resulting request identifiers. + /// + /// The candidate plan to execute. + /// The cancellation token. + /// The resolved request identifiers. + public async Task> ExecuteAsync(RedisMutationRequestCandidatePlan plan, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(plan); + + return plan.Operation switch + { + RedisMutationRequestCandidateOperation.ExplicitIds => plan.ExplicitRequestIds ?? [], + RedisMutationRequestCandidateOperation.SingleSet => await LoadSingleSetAsync(plan, cancellationToken).ConfigureAwait(false), + RedisMutationRequestCandidateOperation.Union => await LoadUnionAsync(plan, cancellationToken).ConfigureAwait(false), + RedisMutationRequestCandidateOperation.Intersection => await LoadIntersectionAsync(plan, cancellationToken).ConfigureAwait(false), + _ => throw new InvalidOperationException($"Unsupported candidate operation '{plan.Operation}'.") + }; + } + + private Task> LoadSingleSetAsync(RedisMutationRequestCandidatePlan plan, CancellationToken cancellationToken) => + _idSetReader.LoadIdsAsync(plan.Keys[0], cancellationToken); + + private Task> LoadUnionAsync(RedisMutationRequestCandidatePlan plan, CancellationToken cancellationToken) => + _idSetReader.LoadUnionedIdsAsync(plan.Keys, cancellationToken); + + private Task> LoadIntersectionAsync(RedisMutationRequestCandidatePlan plan, CancellationToken cancellationToken) => + _idSetReader.LoadIntersectedIdsAsync(plan.Keys[0], plan.Keys[1], cancellationToken); +} diff --git a/src/Redis/Storage/Candidates/Models/RedisMutationRequestCandidateOperation.cs b/src/Redis/Storage/Candidates/Models/RedisMutationRequestCandidateOperation.cs new file mode 100644 index 0000000..e082f2f --- /dev/null +++ b/src/Redis/Storage/Candidates/Models/RedisMutationRequestCandidateOperation.cs @@ -0,0 +1,27 @@ +namespace ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Models; + +/// +/// Defines how Redis request id candidates should be loaded for queries. +/// +internal enum RedisMutationRequestCandidateOperation +{ + /// + /// Uses an already known explicit list of request identifiers. + /// + ExplicitIds = 0, + + /// + /// Loads identifiers from a single Redis set. + /// + SingleSet = 1, + + /// + /// Loads identifiers from the union of multiple Redis sets. + /// + Union = 2, + + /// + /// Loads identifiers from the intersection of multiple Redis sets. + /// + Intersection = 3 +} diff --git a/src/Redis/Storage/Candidates/Models/RedisMutationRequestCandidatePlan.cs b/src/Redis/Storage/Candidates/Models/RedisMutationRequestCandidatePlan.cs new file mode 100644 index 0000000..9d4ac83 --- /dev/null +++ b/src/Redis/Storage/Candidates/Models/RedisMutationRequestCandidatePlan.cs @@ -0,0 +1,14 @@ +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Models; + +/// +/// Represents a planned Redis candidate-id lookup operation. +/// +/// The candidate lookup operation to execute. +/// The Redis keys participating in the operation. +/// The explicit request identifiers when no Redis set lookup is required. +internal sealed record RedisMutationRequestCandidatePlan( + RedisMutationRequestCandidateOperation Operation, + IReadOnlyList Keys, + IReadOnlyList? ExplicitRequestIds = null); diff --git a/src/Redis/Storage/Candidates/Planning/RedisMutationRequestCandidatePlanBuilder.cs b/src/Redis/Storage/Candidates/Planning/RedisMutationRequestCandidatePlanBuilder.cs new file mode 100644 index 0000000..dd08afd --- /dev/null +++ b/src/Redis/Storage/Candidates/Planning/RedisMutationRequestCandidatePlanBuilder.cs @@ -0,0 +1,103 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Redis.Keys; +using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Models; +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Planning; + +/// +/// Builds candidate-id lookup plans for Redis-backed request queries. +/// +internal sealed class RedisMutationRequestCandidatePlanBuilder(RedisMutationRequestKeyspace keyspace) +{ + private readonly RedisMutationRequestKeyspace _keyspace = keyspace ?? throw new ArgumentNullException(nameof(keyspace)); + + /// + /// Builds a plan that loads all known request identifiers. + /// + /// The candidate plan. + public RedisMutationRequestCandidatePlan BuildAllRequestsPlan() + => Single(_keyspace.RequestIds()); + + /// + /// Builds a plan that loads request identifiers for a specific state. + /// + /// The state identifier. + /// The candidate plan. + public RedisMutationRequestCandidatePlan BuildByStateIdPlan(string stateId) + => Single(_keyspace.RequestsByStateId(stateId)); + + /// + /// Builds a plan that loads pending request identifiers, optionally narrowed by pending reason. + /// + /// The optional pending reason. + /// The candidate plan. + public RedisMutationRequestCandidatePlan BuildPendingPlan(PendingMutationReason? reason) + => Single(GetPendingKey(reason)); + + /// + /// Builds a plan that loads pending request identifiers for a specific state. + /// + /// The state identifier. + /// The optional pending reason. + /// The candidate plan. + public RedisMutationRequestCandidatePlan BuildPendingByStateIdPlan(string stateId, PendingMutationReason? reason) + => Intersect(_keyspace.RequestsByStateId(stateId), GetPendingKey(reason)); + + /// + /// Builds a best-effort Redis candidate plan for the supplied request query. + /// + /// The request query to analyze. + /// The candidate plan. + public RedisMutationRequestCandidatePlan BuildQueryPlan(MutationRequestQuery query) + { + ArgumentNullException.ThrowIfNull(query); + + if (query.RequestIds.Count > 0) + return Explicit(query.RequestIds); + + if (query.PendingReasons.Count > 0) + return Union(query.PendingReasons.Select(_keyspace.PendingRequestIds)); + + if (query.Statuses.Count > 0) + { + var pendingOnly = query.Statuses.All(status => status == MutationRequestStatus.Pending); + return pendingOnly + ? BuildPendingPlan(reason: null) + : Union(query.Statuses.Select(_keyspace.RequestsByStatus)); + } + + if (query.StateIds.Count > 0) + return Union(query.StateIds.Select(_keyspace.RequestsByStateId)); + + return BuildAllRequestsPlan(); + } + + private RedisMutationRequestCandidatePlan Explicit(IEnumerable requestIds) + => new(RedisMutationRequestCandidateOperation.ExplicitIds, Keys: [], + ExplicitRequestIds: requestIds + .Where(requestId => !string.IsNullOrWhiteSpace(requestId)) + .Distinct(StringComparer.Ordinal) + .ToArray()); + + private RedisMutationRequestCandidatePlan Single(RedisKey key) + => new(RedisMutationRequestCandidateOperation.SingleSet, Keys: [key]); + + private RedisMutationRequestCandidatePlan Union(IEnumerable keys) + { + var materialized = keys.Distinct().ToArray(); + if (materialized.Length == 1) + return Single(materialized[0]); + + return new RedisMutationRequestCandidatePlan( + RedisMutationRequestCandidateOperation.Union, + materialized); + } + + private RedisMutationRequestCandidatePlan Intersect(RedisKey left, RedisKey right) + => new(RedisMutationRequestCandidateOperation.Intersection, [left, right]); + + private RedisKey GetPendingKey(PendingMutationReason? reason) + => reason.HasValue ? _keyspace.PendingRequestIds(reason.Value) : _keyspace.PendingRequestIds(); +} diff --git a/src/Redis/Storage/Candidates/RedisMutationRequestQueryCandidateSelector.cs b/src/Redis/Storage/Candidates/RedisMutationRequestQueryCandidateSelector.cs new file mode 100644 index 0000000..b642e19 --- /dev/null +++ b/src/Redis/Storage/Candidates/RedisMutationRequestQueryCandidateSelector.cs @@ -0,0 +1,69 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Execution; +using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Models; +using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Planning; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Candidates; + +/// +/// Selects Redis request-id candidates for higher-level request queries. +/// +internal sealed class RedisMutationRequestQueryCandidateSelector( + RedisMutationRequestCandidatePlanBuilder planBuilder, + RedisMutationRequestCandidateExecutor candidateExecutor) +{ + private readonly RedisMutationRequestCandidatePlanBuilder _planBuilder = + planBuilder ?? throw new ArgumentNullException(nameof(planBuilder)); + + private readonly RedisMutationRequestCandidateExecutor _candidateExecutor = + candidateExecutor ?? throw new ArgumentNullException(nameof(candidateExecutor)); + + /// + /// Loads all known request identifiers. + /// + /// The cancellation token. + /// The resolved request identifiers. + public Task> LoadAllRequestIdsAsync(CancellationToken cancellationToken) => + LoadAsync(_planBuilder.BuildAllRequestsPlan(), cancellationToken); + + /// + /// Loads request identifiers for a specific state. + /// + /// The state identifier. + /// The cancellation token. + /// The resolved request identifiers. + public Task> LoadByStateIdAsync(string stateId, CancellationToken cancellationToken) => + LoadAsync(_planBuilder.BuildByStateIdPlan(stateId), cancellationToken); + + /// + /// Loads pending request identifiers, optionally narrowed by reason. + /// + /// The optional pending reason. + /// The cancellation token. + /// The resolved request identifiers. + public Task> LoadPendingAsync(PendingMutationReason? reason, CancellationToken cancellationToken) => + LoadAsync(_planBuilder.BuildPendingPlan(reason), cancellationToken); + + /// + /// Loads pending request identifiers for a specific state. + /// + /// The state identifier. + /// The optional pending reason. + /// The cancellation token. + /// The resolved request identifiers. + public Task> LoadPendingByStateIdAsync(string stateId, PendingMutationReason? reason, CancellationToken cancellationToken) => + LoadAsync(_planBuilder.BuildPendingByStateIdPlan(stateId, reason), cancellationToken); + + /// + /// Loads request identifiers for a general request query using Redis-side candidate narrowing. + /// + /// The query to analyze. + /// The cancellation token. + /// The resolved request identifiers. + public Task> LoadQueryCandidatesAsync(MutationRequestQuery query, CancellationToken cancellationToken) => + LoadAsync(_planBuilder.BuildQueryPlan(query), cancellationToken); + + private Task> LoadAsync(RedisMutationRequestCandidatePlan plan, CancellationToken cancellationToken) => + _candidateExecutor.ExecuteAsync(plan, cancellationToken); +} diff --git a/src/Redis/Storage/Documents/Keys/RedisMutationRequestDocumentKeyFactory.cs b/src/Redis/Storage/Documents/Keys/RedisMutationRequestDocumentKeyFactory.cs new file mode 100644 index 0000000..1320896 --- /dev/null +++ b/src/Redis/Storage/Documents/Keys/RedisMutationRequestDocumentKeyFactory.cs @@ -0,0 +1,23 @@ +using ModularityKit.Mutator.Governance.Redis.Keys; +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Documents.Keys; + +/// +/// Creates Redis document keys for governed mutation requests. +/// +internal sealed class RedisMutationRequestDocumentKeyFactory(RedisMutationRequestKeyspace keyspace) +{ + private readonly RedisMutationRequestKeyspace _keyspace = keyspace ?? throw new ArgumentNullException(nameof(keyspace)); + + /// + /// Creates document keys for the supplied request identifiers. + /// + /// The request identifiers to map. + /// The Redis document keys. + public IReadOnlyList CreateKeys(IEnumerable requestIds) => + requestIds.Where(requestId => !string.IsNullOrWhiteSpace(requestId)) + .Distinct(StringComparer.Ordinal) + .Select(_keyspace.RequestData) + .ToArray(); +} diff --git a/src/Redis/Storage/Documents/Materialization/RedisMutationRequestDocumentMaterializer.cs b/src/Redis/Storage/Documents/Materialization/RedisMutationRequestDocumentMaterializer.cs new file mode 100644 index 0000000..8cf02db --- /dev/null +++ b/src/Redis/Storage/Documents/Materialization/RedisMutationRequestDocumentMaterializer.cs @@ -0,0 +1,38 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Serialization; +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Documents.Materialization; + +/// +/// Materializes governed mutation requests from Redis payload values. +/// +internal static class RedisMutationRequestDocumentMaterializer +{ + /// + /// Deserializes request payloads and optionally orders the resulting requests by creation time. + /// + /// The raw Redis payload values. + /// The cancellation token. + /// Whether to order results by creation timestamp. + /// The materialized mutation requests. + public static IReadOnlyList Materialize(IReadOnlyList values, CancellationToken cancellationToken, bool orderByCreated) + { + var requests = new List(values.Count); + + foreach (var value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (value.HasValue) + requests.Add(RedisMutationRequestSerializer.Deserialize(value!)); + } + + if (!orderByCreated) + return requests; + + return requests.OrderBy(request => request.CreatedAt) + .ThenBy(request => request.RequestId) + .ToList(); + } +} diff --git a/src/Redis/Storage/Documents/Payloads/RedisMutationRequestPayloadReader.cs b/src/Redis/Storage/Documents/Payloads/RedisMutationRequestPayloadReader.cs new file mode 100644 index 0000000..48661c2 --- /dev/null +++ b/src/Redis/Storage/Documents/Payloads/RedisMutationRequestPayloadReader.cs @@ -0,0 +1,28 @@ +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Documents.Payloads; + +/// +/// Loads raw request document payloads from Redis. +/// +internal sealed class RedisMutationRequestPayloadReader(IDatabase database) +{ + private readonly IDatabase _database = database ?? throw new ArgumentNullException(nameof(database)); + + /// + /// Loads raw payload values for the supplied document keys. + /// + /// The Redis document keys to read. + /// The cancellation token. + /// The raw Redis payload values. + public async Task> LoadAsync(IReadOnlyList keys, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (keys.Count == 0) + return []; + + var values = await _database.StringGetAsync(keys.ToArray()).ConfigureAwait(false); + return values; + } +} diff --git a/src/Redis/Storage/Documents/Reading/RedisMutationRequestDocumentReader.cs b/src/Redis/Storage/Documents/Reading/RedisMutationRequestDocumentReader.cs new file mode 100644 index 0000000..b960938 --- /dev/null +++ b/src/Redis/Storage/Documents/Reading/RedisMutationRequestDocumentReader.cs @@ -0,0 +1,47 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Keys; +using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Materialization; +using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Payloads; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Documents.Reading; + +/// +/// Coordinates Redis document key creation, payload reads, and request materialization. +/// +internal sealed class RedisMutationRequestDocumentReader( + RedisMutationRequestDocumentKeyFactory keyFactory, + RedisMutationRequestPayloadReader payloadReader) +{ + private readonly RedisMutationRequestDocumentKeyFactory _keyFactory = + keyFactory ?? throw new ArgumentNullException(nameof(keyFactory)); + private readonly RedisMutationRequestPayloadReader _payloadReader = + payloadReader ?? throw new ArgumentNullException(nameof(payloadReader)); + + /// + /// Loads request documents ordered by request creation time. + /// + /// The request identifiers to load. + /// The cancellation token. + /// The materialized and ordered mutation requests. + public Task> LoadOrderedByCreatedAsync(IEnumerable requestIds, CancellationToken cancellationToken) => + LoadAsync(requestIds, cancellationToken, orderByCreated: true); + + /// + /// Loads request documents for the supplied identifiers. + /// + /// The request identifiers to load. + /// The cancellation token. + /// Whether to order results by request creation time. + /// The materialized mutation requests. + public async Task> LoadAsync(IEnumerable requestIds, CancellationToken cancellationToken, bool orderByCreated) + { + cancellationToken.ThrowIfCancellationRequested(); + + var keys = _keyFactory.CreateKeys(requestIds); + if (keys.Count == 0) + return []; + + var values = await _payloadReader.LoadAsync(keys, cancellationToken).ConfigureAwait(false); + return RedisMutationRequestDocumentMaterializer.Materialize(values, cancellationToken, orderByCreated); + } +} diff --git a/src/Redis/Storage/Identifiers/Loading/RedisMutationRequestIdSetReader.cs b/src/Redis/Storage/Identifiers/Loading/RedisMutationRequestIdSetReader.cs new file mode 100644 index 0000000..5f580e1 --- /dev/null +++ b/src/Redis/Storage/Identifiers/Loading/RedisMutationRequestIdSetReader.cs @@ -0,0 +1,44 @@ +using ModularityKit.Mutator.Governance.Redis.Storage.Identifiers.Models; +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Identifiers.Loading; + +/// +/// Provides higher level Redis request id set reads for candidate execution. +/// +internal sealed class RedisMutationRequestIdSetReader( + RedisMutationRequestIdentifierSetLoader setLoader) +{ + private readonly RedisMutationRequestIdentifierSetLoader _setLoader = setLoader ?? throw new ArgumentNullException(nameof(setLoader)); + + /// + /// Loads request identifiers from a single Redis set. + /// + /// The Redis set key. + /// The cancellation token. + /// The resolved request identifiers. + public async Task> LoadIdsAsync(RedisKey key, CancellationToken cancellationToken) => + await _setLoader.LoadAsync(RedisMutationRequestIdentifierSetOperation.Members, [key], cancellationToken) + .ConfigureAwait(false); + + /// + /// Loads request identifiers from the union of the supplied Redis sets. + /// + /// The Redis set keys to union. + /// The cancellation token. + /// The resolved request identifiers. + public async Task> LoadUnionedIdsAsync(IReadOnlyList keys, CancellationToken cancellationToken) => + await _setLoader.LoadAsync(RedisMutationRequestIdentifierSetOperation.Union, keys, cancellationToken) + .ConfigureAwait(false); + + /// + /// Loads request identifiers from the intersection of two Redis sets. + /// + /// The left Redis set key. + /// The right Redis set key. + /// The cancellation token. + /// The resolved request identifiers. + public async Task> LoadIntersectedIdsAsync(RedisKey left, RedisKey right, CancellationToken cancellationToken) => + await _setLoader.LoadAsync(RedisMutationRequestIdentifierSetOperation.Intersection, [left, right], cancellationToken) + .ConfigureAwait(false); +} diff --git a/src/Redis/Storage/Identifiers/Loading/RedisMutationRequestIdentifierSetLoader.cs b/src/Redis/Storage/Identifiers/Loading/RedisMutationRequestIdentifierSetLoader.cs new file mode 100644 index 0000000..27d63eb --- /dev/null +++ b/src/Redis/Storage/Identifiers/Loading/RedisMutationRequestIdentifierSetLoader.cs @@ -0,0 +1,55 @@ +using ModularityKit.Mutator.Governance.Redis.Storage.Identifiers.Models; +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Identifiers.Loading; + +/// +/// Executes low-level Redis set operations used to resolve request identifiers. +/// +internal sealed class RedisMutationRequestIdentifierSetLoader(IDatabase database) +{ + private readonly IDatabase _database = database ?? throw new ArgumentNullException(nameof(database)); + + /// + /// Loads request identifiers using the supplied Redis set operation. + /// + /// The Redis set operation to execute. + /// The Redis set keys participating in the operation. + /// The cancellation token. + /// The normalized request identifiers. + public async Task> LoadAsync(RedisMutationRequestIdentifierSetOperation operation, IReadOnlyList keys, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (keys.Count == 0) + return []; + + var values = operation switch + { + RedisMutationRequestIdentifierSetOperation.Members => await _database + .SetMembersAsync(keys[0]) + .ConfigureAwait(false), + RedisMutationRequestIdentifierSetOperation.Union => await LoadCombinedAsync( + SetOperation.Union, + keys, + cancellationToken).ConfigureAwait(false), + RedisMutationRequestIdentifierSetOperation.Intersection => await LoadCombinedAsync( + SetOperation.Intersect, + keys, + cancellationToken).ConfigureAwait(false), + _ => throw new InvalidOperationException($"Unsupported identifier set operation '{operation}'.") + }; + + return RedisMutationRequestIdentifierValueNormalizer.Normalize(values); + } + + private async Task LoadCombinedAsync(SetOperation operation, IReadOnlyList keys, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (keys.Count == 1) + return await _database.SetMembersAsync(keys[0]).ConfigureAwait(false); + + return await _database.SetCombineAsync(operation, keys.ToArray()).ConfigureAwait(false); + } +} diff --git a/src/Redis/Storage/Identifiers/Loading/RedisMutationRequestIdentifierValueNormalizer.cs b/src/Redis/Storage/Identifiers/Loading/RedisMutationRequestIdentifierValueNormalizer.cs new file mode 100644 index 0000000..bf4edda --- /dev/null +++ b/src/Redis/Storage/Identifiers/Loading/RedisMutationRequestIdentifierValueNormalizer.cs @@ -0,0 +1,21 @@ +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Identifiers.Loading; + +/// +/// Normalizes raw Redis set members into stable request-id lists. +/// +internal static class RedisMutationRequestIdentifierValueNormalizer +{ + /// + /// Converts Redis values into distinct, non-empty request identifiers. + /// + /// The Redis values to normalize. + /// The normalized request identifiers. + public static IReadOnlyList Normalize(RedisValue[] values) => + values.Where(value => value.HasValue) + .Select(value => value.ToString()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); +} diff --git a/src/Redis/Storage/Identifiers/Models/RedisMutationRequestIdentifierSetOperation.cs b/src/Redis/Storage/Identifiers/Models/RedisMutationRequestIdentifierSetOperation.cs new file mode 100644 index 0000000..b2e3378 --- /dev/null +++ b/src/Redis/Storage/Identifiers/Models/RedisMutationRequestIdentifierSetOperation.cs @@ -0,0 +1,22 @@ +namespace ModularityKit.Mutator.Governance.Redis.Storage.Identifiers.Models; + +/// +/// Defines the Redis set operation used to resolve request identifiers. +/// +internal enum RedisMutationRequestIdentifierSetOperation +{ + /// + /// Reads the members of a single Redis set. + /// + Members = 0, + + /// + /// Reads the union of multiple Redis sets. + /// + Union = 1, + + /// + /// Reads the intersection of multiple Redis sets. + /// + Intersection = 2 +} diff --git a/src/Redis/Storage/Persistence/Models/RedisMutationRequestPersistenceRecord.cs b/src/Redis/Storage/Persistence/Models/RedisMutationRequestPersistenceRecord.cs new file mode 100644 index 0000000..6bfa3fa --- /dev/null +++ b/src/Redis/Storage/Persistence/Models/RedisMutationRequestPersistenceRecord.cs @@ -0,0 +1,18 @@ +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Models; + +/// +/// Represents the Redis persistence payload for governed mutation request write. +/// +/// The governed request identifier. +/// The Redis key for the serialized request document. +/// The Redis key for the request revision value. +/// The serialized request payload. +/// The revision value to persist. +internal sealed record RedisMutationRequestPersistenceRecord( + string RequestId, + RedisKey DataKey, + RedisKey RevisionKey, + RedisValue Payload, + long Revision); diff --git a/src/Redis/Storage/Persistence/Reading/RedisMutationRequestPersistenceDocumentReader.cs b/src/Redis/Storage/Persistence/Reading/RedisMutationRequestPersistenceDocumentReader.cs new file mode 100644 index 0000000..e8eafeb --- /dev/null +++ b/src/Redis/Storage/Persistence/Reading/RedisMutationRequestPersistenceDocumentReader.cs @@ -0,0 +1,35 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Keys; +using ModularityKit.Mutator.Governance.Redis.Serialization; +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Reading; + +/// +/// Reads individual governed mutation requests from Redis persistence storage. +/// +internal sealed class RedisMutationRequestPersistenceDocumentReader( + IDatabase database, + RedisMutationRequestKeyspace keyspace) +{ + private readonly IDatabase _database = database ?? throw new ArgumentNullException(nameof(database)); + private readonly RedisMutationRequestKeyspace _keyspace = keyspace ?? throw new ArgumentNullException(nameof(keyspace)); + + /// + /// Reads a single governed mutation request by identifier. + /// + /// The request identifier. + /// The cancellation token. + /// The request if it exists; otherwise . + public async Task GetAsync( + string requestId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var payload = await _database.StringGetAsync(_keyspace.RequestData(requestId)).ConfigureAwait(false); + return payload.HasValue + ? RedisMutationRequestSerializer.Deserialize(payload!) + : null; + } +} diff --git a/src/Redis/Storage/Persistence/RedisMutationRequestPersistence.cs b/src/Redis/Storage/Persistence/RedisMutationRequestPersistence.cs new file mode 100644 index 0000000..b099d86 --- /dev/null +++ b/src/Redis/Storage/Persistence/RedisMutationRequestPersistence.cs @@ -0,0 +1,79 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Reading; +using ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Writing; +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Persistence; + +/// +/// Coordinates Redis persistence operations for governed mutation requests. +/// +internal sealed class RedisMutationRequestPersistence( + IDatabase database, + RedisMutationRequestPersistenceRecordFactory recordFactory, + RedisMutationRequestPersistenceDocumentReader documentReader, + RedisMutationRequestTransactionWriter transactionWriter) +{ + private readonly IDatabase _database = database ?? throw new ArgumentNullException(nameof(database)); + private readonly RedisMutationRequestPersistenceRecordFactory _recordFactory = recordFactory ?? throw new ArgumentNullException(nameof(recordFactory)); + private readonly RedisMutationRequestPersistenceDocumentReader _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); + private readonly RedisMutationRequestTransactionWriter _transactionWriter = transactionWriter ?? throw new ArgumentNullException(nameof(transactionWriter)); + + /// + /// Creates a new governed mutation request in Redis with an initial revision. + /// + /// The request to create. + /// The cancellation token. + /// The persisted request with provider-managed revision values applied. + public async Task Create(MutationRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); + + var persistedRequest = request with { Revision = 0 }; + var record = _recordFactory.Create(persistedRequest); + var transaction = _database.CreateTransaction(); + + _transactionWriter.WriteCreate(transaction, record, persistedRequest); + + var committed = await transaction.ExecuteAsync().ConfigureAwait(false); + return !committed + ? throw new InvalidOperationException($"Mutation request '{request.RequestId}' already exists in Redis.") + : persistedRequest; + } + + /// + /// Attempts to store an updated governed mutation request using optimistic concurrency. + /// + /// The request to persist. + /// The expected current revision. + /// The cancellation token. + /// The persisted request if the update succeeds; otherwise . + public async Task TryStore(MutationRequest request, long expectedRevision, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); + + var currentRequest = await Get(request.RequestId, cancellationToken).ConfigureAwait(false); + if (currentRequest is null || currentRequest.Revision != expectedRevision) + return null; + + var persistedRequest = request with { Revision = expectedRevision + 1 }; + var record = _recordFactory.Create(persistedRequest); + var transaction = _database.CreateTransaction(); + + _transactionWriter.WriteUpdate(transaction, record, expectedRevision, currentRequest, persistedRequest); + + var committed = await transaction.ExecuteAsync().ConfigureAwait(false); + return committed ? persistedRequest : null; + } + + /// + /// Reads a governed mutation request by identifier. + /// + /// The request identifier. + /// The cancellation token. + /// The request if it exists; otherwise . + public async Task Get(string requestId, CancellationToken cancellationToken = default) => + await _documentReader.GetAsync(requestId, cancellationToken).ConfigureAwait(false); +} diff --git a/src/Redis/Storage/Persistence/Writing/RedisMutationRequestIndexWriter.cs b/src/Redis/Storage/Persistence/Writing/RedisMutationRequestIndexWriter.cs new file mode 100644 index 0000000..047c8b0 --- /dev/null +++ b/src/Redis/Storage/Persistence/Writing/RedisMutationRequestIndexWriter.cs @@ -0,0 +1,42 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Keys; +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Writing; + +/// +/// Maintains Redis secondary indexes for governed mutation request writes. +/// +internal sealed class RedisMutationRequestIndexWriter( + RedisMutationRequestKeyspace keyspace) +{ + private readonly RedisMutationRequestKeyspace _keyspace = keyspace ?? throw new ArgumentNullException(nameof(keyspace)); + + /// + /// Adds a request to all Redis secondary indexes implied by its current state. + /// + /// The Redis transaction to append commands to. + /// The request to index. + public void Add(ITransaction transaction, MutationRequest request) + { + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(request); + + foreach (var indexKey in _keyspace.EnumerateIndexes(request)) + _ = transaction.SetAddAsync(indexKey, request.RequestId); + } + + /// + /// Removes a request from all Redis secondary indexes implied by its current state. + /// + /// The Redis transaction to append commands to. + /// The request to remove from indexes. + public void Remove(ITransaction transaction, MutationRequest request) + { + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(request); + + foreach (var indexKey in _keyspace.EnumerateIndexes(request)) + _ = transaction.SetRemoveAsync(indexKey, request.RequestId); + } +} diff --git a/src/Redis/Storage/Persistence/Writing/RedisMutationRequestPersistenceRecordFactory.cs b/src/Redis/Storage/Persistence/Writing/RedisMutationRequestPersistenceRecordFactory.cs new file mode 100644 index 0000000..061bdd7 --- /dev/null +++ b/src/Redis/Storage/Persistence/Writing/RedisMutationRequestPersistenceRecordFactory.cs @@ -0,0 +1,32 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Keys; +using ModularityKit.Mutator.Governance.Redis.Serialization; +using ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Models; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Writing; + +/// +/// Creates Redis persistence records from governed mutation requests. +/// +internal sealed class RedisMutationRequestPersistenceRecordFactory( + RedisMutationRequestKeyspace keyspace) +{ + private readonly RedisMutationRequestKeyspace _keyspace = keyspace ?? throw new ArgumentNullException(nameof(keyspace)); + + /// + /// Creates a persistence record for the supplied request. + /// + /// The request to convert. + /// The Redis persistence record. + public RedisMutationRequestPersistenceRecord Create(MutationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + return new RedisMutationRequestPersistenceRecord( + request.RequestId, + _keyspace.RequestData(request.RequestId), + _keyspace.RequestRevision(request.RequestId), + RedisMutationRequestSerializer.Serialize(request), + request.Revision); + } +} diff --git a/src/Redis/Storage/Persistence/Writing/RedisMutationRequestTransactionWriter.cs b/src/Redis/Storage/Persistence/Writing/RedisMutationRequestTransactionWriter.cs new file mode 100644 index 0000000..29549ff --- /dev/null +++ b/src/Redis/Storage/Persistence/Writing/RedisMutationRequestTransactionWriter.cs @@ -0,0 +1,60 @@ +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Models; +using StackExchange.Redis; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Writing; + +/// +/// Appends Redis transaction commands for governed mutation request create and update operations. +/// +internal sealed class RedisMutationRequestTransactionWriter( + RedisMutationRequestIndexWriter indexWriter) +{ + private readonly RedisMutationRequestIndexWriter _indexWriter = indexWriter ?? throw new ArgumentNullException(nameof(indexWriter)); + + /// + /// Writes the Redis transaction commands required to create a new request. + /// + /// The Redis transaction to append commands to. + /// The persistence record to store. + /// The request being created. + public void WriteCreate(ITransaction transaction, RedisMutationRequestPersistenceRecord record, MutationRequest request) + { + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(request); + + _ = transaction.AddCondition(Condition.KeyNotExists(record.DataKey)); + _ = transaction.AddCondition(Condition.KeyNotExists(record.RevisionKey)); + _ = transaction.StringSetAsync(record.DataKey, record.Payload); + _ = transaction.StringSetAsync(record.RevisionKey, record.Revision); + _indexWriter.Add(transaction, request); + } + + /// + /// Writes the Redis transaction commands required to update an existing request revision. + /// + /// The Redis transaction to append commands to. + /// The persistence record to store. + /// The expected current revision value. + /// The currently stored request state. + /// The updated request state to persist. + public void WriteUpdate( + ITransaction transaction, + RedisMutationRequestPersistenceRecord record, + long expectedRevision, + MutationRequest currentRequest, + MutationRequest persistedRequest) + { + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(currentRequest); + ArgumentNullException.ThrowIfNull(persistedRequest); + + _ = transaction.AddCondition(Condition.StringEqual(record.RevisionKey, expectedRevision)); + _ = transaction.StringSetAsync(record.DataKey, record.Payload); + _ = transaction.StringSetAsync(record.RevisionKey, record.Revision); + _indexWriter.Remove(transaction, currentRequest); + _indexWriter.Add(transaction, persistedRequest); + } +} diff --git a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs new file mode 100644 index 0000000..c035202 --- /dev/null +++ b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestOrdering.cs @@ -0,0 +1,55 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; + +/// +/// Applies common ordering rules for Redis backed governance query results. +/// +internal static class RedisMutationRequestOrdering +{ + public static IReadOnlyList ByCreated(IEnumerable requests) + => requests + .OrderBy(request => request.CreatedAt) + .ThenBy(request => request.RequestId) + .ToList(); + + public static IReadOnlyList ByRecentApprovals( + IEnumerable requests, + int? take) + { + IEnumerable results = requests + .OrderByDescending(MutationRequestQueryEvaluator.GetRecentApprovalTimestamp) + .ThenByDescending(request => request.UpdatedAt) + .ThenBy(request => request.RequestId); + + if (take is >= 0) + results = results.Take(take.Value); + + return results.ToList(); + } + + public static IReadOnlyList ByPendingApprovalView( + IEnumerable views) + => views + .OrderBy(view => view.Request.CreatedAt) + .ThenBy(view => view.Request.RequestId) + .ThenBy(view => view.Approval.StepOrder) + .ThenBy(view => view.Approval.ApprovalId) + .ToList(); + + public static IReadOnlyList ByRecentDecisionView( + IEnumerable views, + int? take) + { + IEnumerable results = views + .OrderByDescending(view => view.Decision.Timestamp) + .ThenByDescending(view => view.Request.UpdatedAt) + .ThenBy(view => view.Request.RequestId); + + if (take is >= 0) + results = results.Take(take.Value); + + return results.ToList(); + } +} diff --git a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs new file mode 100644 index 0000000..18a128a --- /dev/null +++ b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestQueryMaterializer.cs @@ -0,0 +1,132 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; + +/// +/// Applies governance query evaluators to materialized Redis request documents. +/// +internal static class RedisMutationRequestQueryMaterializer +{ + /// + /// Applies a general request query to already materialized requests. + /// + /// The materialized requests. + /// The query to evaluate. + /// The filtered request results. + public static IReadOnlyList ApplyQuery( + IEnumerable requests, + MutationRequestQuery query) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentNullException.ThrowIfNull(query); + + return RedisMutationRequestOrdering.ByCreated( + requests.Where(request => MutationRequestQueryEvaluator.Matches(request, query))); + } + + /// + /// Applies a pending request query to already materialized requests. + /// + /// The materialized requests. + /// The query to evaluate. + /// The filtered pending-request results. + public static IReadOnlyList ApplyPendingQuery( + IEnumerable requests, + MutationRequestQuery query) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentNullException.ThrowIfNull(query); + + return RedisMutationRequestOrdering.ByCreated( + requests.Where(request => + request.Status == MutationRequestStatus.Pending && + MutationRequestQueryEvaluator.Matches(request, query))); + } + + /// + /// Applies a pending approval queue query to already materialized requests. + /// + /// The materialized requests. + /// The query to evaluate. + /// The filtered pending-approval-queue results. + public static IReadOnlyList ApplyPendingApprovalQueueQuery( + IEnumerable requests, + MutationRequestQuery query) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentNullException.ThrowIfNull(query); + + return RedisMutationRequestOrdering.ByCreated( + requests.Where(request => + request.Status == MutationRequestStatus.Pending && + request.PendingReason == PendingMutationReason.Approval && + MutationRequestQueryEvaluator.Matches(request, query))); + } + + /// + /// Applies a recent approvals query to already materialized requests. + /// + /// The materialized requests. + /// The query to evaluate. + /// An optional result limit. + /// The filtered recent-approval results. + public static IReadOnlyList ApplyRecentApprovalsQuery( + IEnumerable requests, + MutationRequestQuery query, + int? take) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentNullException.ThrowIfNull(query); + + var results = requests + .Where(request => + MutationRequestQueryEvaluator.Matches(request, query) && + MutationRequestQueryEvaluator.HasApprovalActivity(request)); + + return RedisMutationRequestOrdering.ByRecentApprovals(results, take); + } + + /// + /// Applies a pending approval view query to already materialized requests. + /// + /// The materialized requests. + /// The approval query to evaluate. + /// The filtered approval-view results. + public static IReadOnlyList ApplyPendingApprovalViewQuery( + IEnumerable requests, + MutationApprovalQuery query) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentNullException.ThrowIfNull(query); + + var views = RedisMutationRequestViewProjector + .ToApprovalViews(requests) + .Where(view => MutationApprovalQueryEvaluator.Matches(view.Request, view.Approval, query)); + + return RedisMutationRequestOrdering.ByPendingApprovalView(views); + } + + /// + /// Applies a recent decision query to already materialized requests. + /// + /// The materialized requests. + /// The decision query to evaluate. + /// An optional result limit. + /// The filtered decision-view results. + public static IReadOnlyList ApplyRecentDecisionQuery( + IEnumerable requests, + MutationRequestDecisionQuery query, + int? take) + { + ArgumentNullException.ThrowIfNull(requests); + ArgumentNullException.ThrowIfNull(query); + + var views = RedisMutationRequestViewProjector + .ToDecisionViews(requests) + .Where(view => MutationRequestDecisionQueryEvaluator.Matches(view.Request, view.Decision, query)); + + return RedisMutationRequestOrdering.ByRecentDecisionView(views, take); + } +} diff --git a/src/Redis/Storage/Queries/Materialization/RedisMutationRequestViewProjector.cs b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestViewProjector.cs new file mode 100644 index 0000000..e9b2fd4 --- /dev/null +++ b/src/Redis/Storage/Queries/Materialization/RedisMutationRequestViewProjector.cs @@ -0,0 +1,32 @@ +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; + +/// +/// Projects governed request documents into query specific view models. +/// +internal static class RedisMutationRequestViewProjector +{ + public static IEnumerable ToApprovalViews(IEnumerable requests) + { + ArgumentNullException.ThrowIfNull(requests); + + return requests.SelectMany(request => request.ApprovalRequirements.Select(approval => new MutationApprovalView + { + Request = request, + Approval = approval + })); + } + + public static IEnumerable ToDecisionViews(IEnumerable requests) + { + ArgumentNullException.ThrowIfNull(requests); + + return requests.SelectMany(request => request.Decisions.Select(decision => new MutationRequestDecisionView + { + Request = request, + Decision = decision + })); + } +} diff --git a/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryDocumentLoader.cs b/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryDocumentLoader.cs new file mode 100644 index 0000000..46b3a5b --- /dev/null +++ b/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryDocumentLoader.cs @@ -0,0 +1,81 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Storage.Candidates; +using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Reading; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Reading; + +/// +/// Loads governed request documents for Redis backed query flows. +/// +internal sealed class RedisMutationRequestQueryDocumentLoader( + RedisMutationRequestQueryCandidateSelector candidateSelector, + RedisMutationRequestDocumentReader documentReader) +{ + private readonly RedisMutationRequestQueryCandidateSelector _candidateSelector = + candidateSelector ?? throw new ArgumentNullException(nameof(candidateSelector)); + private readonly RedisMutationRequestDocumentReader _documentReader = + documentReader ?? throw new ArgumentNullException(nameof(documentReader)); + + /// + /// Loads governed request documents for a specific state. + /// + /// The state identifier. + /// The cancellation token. + /// The loaded request documents. + public async Task> LoadByStateIdAsync( + string stateId, + CancellationToken cancellationToken) + { + var requestIds = await _candidateSelector.LoadByStateIdAsync(stateId, cancellationToken).ConfigureAwait(false); + return await _documentReader.LoadOrderedByCreatedAsync(requestIds, cancellationToken).ConfigureAwait(false); + } + + /// + /// Loads pending governed request documents, optionally narrowed by pending reason. + /// + /// The optional pending reason. + /// The cancellation token. + /// The loaded request documents. + public async Task> LoadPendingAsync( + PendingMutationReason? reason, + CancellationToken cancellationToken) + { + var requestIds = await _candidateSelector.LoadPendingAsync(reason, cancellationToken).ConfigureAwait(false); + return await _documentReader.LoadOrderedByCreatedAsync(requestIds, cancellationToken).ConfigureAwait(false); + } + + /// + /// Loads pending governed request documents for a specific state. + /// + /// The state identifier. + /// The optional pending reason. + /// The cancellation token. + /// The loaded request documents. + public async Task> LoadPendingByStateIdAsync( + string stateId, + PendingMutationReason? reason, + CancellationToken cancellationToken) + { + var requestIds = await _candidateSelector.LoadPendingByStateIdAsync(stateId, reason, cancellationToken) + .ConfigureAwait(false); + return await _documentReader.LoadOrderedByCreatedAsync(requestIds, cancellationToken).ConfigureAwait(false); + } + + /// + /// Loads governed request documents for a general request query. + /// + /// The query to narrow through Redis candidates. + /// The cancellation token. + /// The loaded request documents. + public async Task> LoadByRequestQueryAsync( + MutationRequestQuery query, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + var requestIds = await _candidateSelector.LoadQueryCandidatesAsync(query, cancellationToken).ConfigureAwait(false); + return await _documentReader.LoadOrderedByCreatedAsync(requestIds, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryReader.cs b/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryReader.cs new file mode 100644 index 0000000..a786a84 --- /dev/null +++ b/src/Redis/Storage/Queries/Reading/RedisMutationRequestQueryReader.cs @@ -0,0 +1,161 @@ +using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model; +using ModularityKit.Mutator.Governance.Abstractions.Queries.Model; +using ModularityKit.Mutator.Governance.Abstractions.Requests.Model; +using ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization; + +namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Reading; + +/// +/// Orchestrates Redis backed governed request query reads. +/// +internal sealed class RedisMutationRequestQueryReader +{ + private readonly RedisMutationRequestQueryDocumentLoader _documentLoader; + + /// + /// Initializes a new query reader instance. + /// + /// The Redis query document loader. + public RedisMutationRequestQueryReader( + RedisMutationRequestQueryDocumentLoader documentLoader) + { + ArgumentNullException.ThrowIfNull(documentLoader); + + _documentLoader = documentLoader; + } + + /// + /// Reads governed requests for a specific state identifier. + /// + /// The state identifier. + /// The cancellation token. + /// The matching requests. + public async Task> GetByStateId( + string stateId, + CancellationToken cancellationToken = default) + => await _documentLoader.LoadByStateIdAsync(stateId, cancellationToken).ConfigureAwait(false); + + /// + /// Reads pending governed requests, optionally narrowed by pending reason. + /// + /// The optional pending reason. + /// The cancellation token. + /// The matching requests. + public async Task> GetPending( + PendingMutationReason? reason = null, + CancellationToken cancellationToken = default) + => await _documentLoader.LoadPendingAsync(reason, cancellationToken).ConfigureAwait(false); + + /// + /// Reads pending governed requests for a specific state identifier. + /// + /// The state identifier. + /// The optional pending reason. + /// The cancellation token. + /// The matching requests. + public async Task> GetPendingByStateId( + string stateId, + PendingMutationReason? reason = null, + CancellationToken cancellationToken = default) + => await _documentLoader.LoadPendingByStateIdAsync(stateId, reason, cancellationToken).ConfigureAwait(false); + + /// + /// Reads governed requests matching the supplied general query. + /// + /// The request query. + /// The cancellation token. + /// The matching requests. + public async Task> QueryAsync( + MutationRequestQuery query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + var requests = await _documentLoader.LoadByRequestQueryAsync(query, cancellationToken).ConfigureAwait(false); + return RedisMutationRequestQueryMaterializer.ApplyQuery(requests, query); + } + + /// + /// Reads pending governed requests and applies an optional in-memory query filter. + /// + /// The optional request query. + /// The cancellation token. + /// The matching pending requests. + public async Task> GetPendingRequestsAsync( + MutationRequestQuery? query = null, + CancellationToken cancellationToken = default) + { + var effectiveQuery = query ?? new MutationRequestQuery(); + var requests = await _documentLoader.LoadPendingAsync(reason: null, cancellationToken).ConfigureAwait(false); + + return RedisMutationRequestQueryMaterializer.ApplyPendingQuery(requests, effectiveQuery); + } + + /// + /// Reads the pending approval queue and applies an optional in-memory query filter. + /// + /// The optional request query. + /// The cancellation token. + /// The matching pending approval-queue requests. + public async Task> GetPendingApprovalQueueAsync( + MutationRequestQuery? query = null, + CancellationToken cancellationToken = default) + { + var effectiveQuery = query ?? new MutationRequestQuery(); + var requests = await _documentLoader.LoadPendingAsync(PendingMutationReason.Approval, cancellationToken) + .ConfigureAwait(false); + + return RedisMutationRequestQueryMaterializer.ApplyPendingApprovalQueueQuery(requests, effectiveQuery); + } + + /// + /// Reads recent approval active governed requests. + /// + /// The optional request query. + /// An optional result limit. + /// The cancellation token. + /// The matching recent-approval requests. + public async Task> GetRecentApprovalsAsync( + MutationRequestQuery? query = null, + int? take = null, + CancellationToken cancellationToken = default) + { + var effectiveQuery = query ?? MutationRequestQuery.RecentApprovals(); + var requests = await _documentLoader.LoadByRequestQueryAsync(effectiveQuery, cancellationToken).ConfigureAwait(false); + return RedisMutationRequestQueryMaterializer.ApplyRecentApprovalsQuery(requests, effectiveQuery, take); + } + + /// + /// Reads pending approval views for governed requests. + /// + /// The optional approval query. + /// The cancellation token. + /// The matching approval views. + public async Task> GetPendingApprovalsAsync( + MutationApprovalQuery? query = null, + CancellationToken cancellationToken = default) + { + var effectiveQuery = query ?? MutationApprovalQuery.Pending(); + var requests = await _documentLoader.LoadByRequestQueryAsync(effectiveQuery.RequestQuery, cancellationToken) + .ConfigureAwait(false); + return RedisMutationRequestQueryMaterializer.ApplyPendingApprovalViewQuery(requests, effectiveQuery); + } + + /// + /// Reads recent decision views for governed requests. + /// + /// The optional decision query. + /// An optional result limit. + /// The cancellation token. + /// The matching decision views. + public async Task> GetRecentDecisionsAsync( + MutationRequestDecisionQuery? query = null, + int? take = null, + CancellationToken cancellationToken = default) + { + var effectiveQuery = query ?? new MutationRequestDecisionQuery(); + var requests = await _documentLoader.LoadByRequestQueryAsync(effectiveQuery.RequestQuery, cancellationToken) + .ConfigureAwait(false); + return RedisMutationRequestQueryMaterializer.ApplyRecentDecisionQuery(requests, effectiveQuery, take); + } +} diff --git a/src/Redis/Storage/RedisMutationRequestStore.cs b/src/Redis/Storage/RedisMutationRequestStore.cs new file mode 100644 index 0000000..7d851f8 --- /dev/null +++ b/src/Redis/Storage/RedisMutationRequestStore.cs @@ -0,0 +1,167 @@ +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.Model; +using ModularityKit.Mutator.Governance.Abstractions.Storage; +using ModularityKit.Mutator.Governance.Redis.Storage.Persistence; +using ModularityKit.Mutator.Governance.Redis.Storage.Queries; +using ModularityKit.Mutator.Governance.Redis.Storage.Queries.Reading; + +namespace ModularityKit.Mutator.Governance.Redis.Storage; + +/// +/// Redis-backed implementation of governed mutation request storage and query access. +/// +public sealed class RedisMutationRequestStore : IMutationRequestStore, IMutationRequestQueryStore +{ + private readonly RedisMutationRequestPersistence _persistence; + private readonly RedisMutationRequestQueryReader _queryReader; + + internal RedisMutationRequestStore( + RedisMutationRequestPersistence persistence, + RedisMutationRequestQueryReader queryReader) + { + _persistence = persistence ?? throw new ArgumentNullException(nameof(persistence)); + _queryReader = queryReader ?? throw new ArgumentNullException(nameof(queryReader)); + } + + /// + /// Creates a governed mutation request in Redis storage. + /// + /// The request to create. + /// The cancellation token. + /// The persisted mutation request. + public Task Create( + MutationRequest request, + CancellationToken cancellationToken = default) + => _persistence.Create(request, cancellationToken); + + /// + /// Attempts to store a governed mutation request update using optimistic concurrency. + /// + /// The request to store. + /// The expected current revision. + /// The cancellation token. + /// The persisted request if the update succeeds; otherwise . + public Task TryStore( + MutationRequest request, + long expectedRevision, + CancellationToken cancellationToken = default) + => _persistence.TryStore(request, expectedRevision, cancellationToken); + + /// + /// Reads a governed mutation request by identifier. + /// + /// The request identifier. + /// The cancellation token. + /// The request if it exists; otherwise . + public Task Get( + string requestId, + CancellationToken cancellationToken = default) + => _persistence.Get(requestId, cancellationToken); + + /// + /// Reads governed mutation requests for a specific state identifier. + /// + /// The state identifier. + /// The cancellation token. + /// The matching requests. + public Task> GetByStateId( + string stateId, + CancellationToken cancellationToken = default) + => _queryReader.GetByStateId(stateId, cancellationToken); + + /// + /// Reads pending governed mutation requests, optionally narrowed by reason. + /// + /// The optional pending reason. + /// The cancellation token. + /// The matching requests. + public Task> GetPending( + PendingMutationReason? reason = null, + CancellationToken cancellationToken = default) + => _queryReader.GetPending(reason, cancellationToken); + + /// + /// Reads pending governed mutation requests for a specific state identifier. + /// + /// The state identifier. + /// The optional pending reason. + /// The cancellation token. + /// The matching requests. + public Task> GetPendingByStateId( + string stateId, + PendingMutationReason? reason = null, + CancellationToken cancellationToken = default) + => _queryReader.GetPendingByStateId(stateId, reason, cancellationToken); + + /// + /// Reads governed mutation requests matching the supplied query. + /// + /// The request query. + /// The cancellation token. + /// The matching requests. + public Task> QueryAsync( + MutationRequestQuery query, + CancellationToken cancellationToken = default) + => _queryReader.QueryAsync(query, cancellationToken); + + /// + /// Reads pending governed mutation requests using an optional additional query filter. + /// + /// The optional request query. + /// The cancellation token. + /// The matching pending requests. + public Task> GetPendingRequestsAsync( + MutationRequestQuery? query = null, + CancellationToken cancellationToken = default) + => _queryReader.GetPendingRequestsAsync(query, cancellationToken); + + /// + /// Reads the pending approval queue using an optional additional query filter. + /// + /// The optional request query. + /// The cancellation token. + /// The matching pending approval-queue requests. + public Task> GetPendingApprovalQueueAsync( + MutationRequestQuery? query = null, + CancellationToken cancellationToken = default) + => _queryReader.GetPendingApprovalQueueAsync(query, cancellationToken); + + /// + /// Recently reads approval active governed mutation requests. + /// + /// The optional request query. + /// The optional result limit. + /// The cancellation token. + /// The matching requests. + public Task> GetRecentApprovalsAsync( + MutationRequestQuery? query = null, + int? take = null, + CancellationToken cancellationToken = default) + => _queryReader.GetRecentApprovalsAsync(query, take, cancellationToken); + + /// + /// Reads pending approval views using an optional approval query filter. + /// + /// The optional approval query. + /// The cancellation token. + /// The matching approval views. + public Task> GetPendingApprovalsAsync( + MutationApprovalQuery? query = null, + CancellationToken cancellationToken = default) + => _queryReader.GetPendingApprovalsAsync(query, cancellationToken); + + /// + /// Reads recent decision views using an optional decision query filter. + /// + /// The optional decision query. + /// The optional result limit. + /// The cancellation token. + /// The matching decision views. + public Task> GetRecentDecisionsAsync( + MutationRequestDecisionQuery? query = null, + int? take = null, + CancellationToken cancellationToken = default) + => _queryReader.GetRecentDecisionsAsync(query, take, cancellationToken); +}