Skip to content

Commit 833687d

Browse files
authored
Feat: Improve governance DX and docs (#40)
## Summary This PR improves governance API ergonomics and restructures the API documentation into a clearer package oriented layout. It reduces call site noise in governance flows, updates the examples to use the shorter patterns, and adds dedicated API guides for governance and Redis. ## Added - category specific `MutationRequestDecision` helpers for lifecycle, approval, and version-resolution decisions - `IVersionedState` execution overload for `IGovernanceExecutionManager.ExecuteApproved(...)` - dedicated `Docs/API/` folder with separate governance, Redis, and core API reference documents - focused tests covering the new `MutationRequestDecision` helper methods ## Changed - governance request factories now use the category specific decision helpers internally - governance runtime execution, approval, and version resolution paths now use the shorter decision APIs - governance examples now demonstrate the simplified DX patterns - governance and Redis package READMEs now point to the dedicated API docs ## Result The governance surface is now shorter and more consistent at the call site. Common operations such as request creation, decision recording, and approved execution require less boilerplate, and the documentation now separates core, governance, and Redis usage more clearly. ## Testing - verified `dotnet test Tests/ModularityKit.Mutator.Governance.Tests/ModularityKit.Mutator.Governance.Tests.csproj` - verified `dotnet build Examples/Governance/GovernedExecution/GovernedExecution.csproj` - verified `dotnet build Examples/Governance/Queries/Queries.csproj` - verified `dotnet build Examples/Governance/RedisQueries/RedisQueries.csproj` - verified `dotnet build Examples/Governance/DecisionTaxonomy/DecisionTaxonomy.csproj` ## Checklist - [x] governance decision creation has dedicated DX helpers - [x] approved execution supports the `IVersionedState` shortcut overload - [x] governance examples are updated to use the shorter API surface - [x] API documentation is split into dedicated core, governance, and Redis guides
2 parents 6333bfd + 05b9650 commit 833687d

42 files changed

Lines changed: 1178 additions & 343 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Complete API documentation for **ModularityKit.Mutators**.
1515
- [Related Types](#related-types)
1616
- [DI Extension Methods](#di-extension-methods)
1717
- [AddMutators](#addmutators)
18+
- [Governance API](#governance-api)
1819
- [Execution Semantics](#execution-semantics)
1920
- [Best Practices](#best-practices)
2021
- [Complete Examples](#complete-examples)
@@ -246,6 +247,19 @@ services.AddMutators(MutationEngineOptions.Strict, addDefaultLoggingInterceptor:
246247

247248
---
248249

250+
## Governance API
251+
252+
Governance is documented separately so the core API reference can stay focused on direct mutation execution.
253+
254+
- [Governance package overview](../../src/Governance/README.md)
255+
- [Governance API usage](API.md)
256+
- [Redis provider API](Redis.md)
257+
258+
Governance-specific types such as `MutationRequestFactory`, `MutationRequestDecision`, and
259+
`IGovernanceExecutionManager` live in the governance package, not the core mutation runtime.
260+
261+
---
262+
249263
## Execution Semantics
250264

251265
1. Call `Validate(state)`
@@ -272,4 +286,4 @@ services.AddMutators(MutationEngineOptions.Strict, addDefaultLoggingInterceptor:
272286

273287
---
274288

275-
> **Built with ❤️ for .NET developers**
289+
> **Built with ❤️ for .NET developers**

Docs/API/API.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Governance API
2+
3+
This page shows the practical entry points used by the governance examples.
4+
5+
## What this API is for
6+
7+
Use the governance API when execution is not immediate and you need one of these steps first:
8+
9+
- request creation
10+
- approval workflow
11+
- stale-version resolution
12+
- governed execution
13+
- request and decision history queries
14+
15+
The core package owns direct mutation execution. Governance wraps that execution with a request
16+
model and stateful decision history.
17+
18+
## Request creation
19+
20+
Use `MutationRequestFactory` when you want to create a request from explicit strings and values:
21+
22+
```csharp
23+
var request = MutationRequestFactory.PendingApproval(
24+
stateId: "tenant-42:roles",
25+
stateType: "IamRoleState",
26+
mutationType: "GrantRoleMutation",
27+
intent: new MutationIntent
28+
{
29+
OperationName = "GrantRole",
30+
Category = "Security",
31+
Description = "Grant elevated role to tenant operator"
32+
},
33+
context: MutationContext.User("requester-1", "Requester One", "Incident escalation"),
34+
expectedStateVersion: "v10",
35+
approvalRequirements:
36+
[
37+
MutationApprovalRequirement.SingleActorStep("security-lead"),
38+
MutationApprovalRequirement.SingleActorStep("platform-owner")
39+
]);
40+
```
41+
42+
If you already have concrete CLR types, prefer the generic overloads such as:
43+
44+
- `MutationRequestFactory.Approved<TState, TMutation>()`
45+
- `MutationRequestFactory.PendingApproval<TState, TMutation>()`
46+
47+
They remove repeated `stateType` and `mutationType` strings at the call site.
48+
49+
### Factory choices
50+
51+
- `Pending(...)` for a request that should enter the lifecycle without approval requirements
52+
- `PendingApproval(...)` for a request that must collect one or more approval steps
53+
- `Approved(...)` for a request that is terminally approved at creation time
54+
55+
Use the generic overloads when the CLR types already exist. Use the string-based overloads when the
56+
request metadata comes from an external system or serialized payload.
57+
58+
## Decision history
59+
60+
Use `MutationRequestDecision` when you need to record a lifecycle, approval, or version-resolution decision and the category is already known:
61+
62+
```csharp
63+
var decision = MutationRequestDecision.Lifecycle(
64+
MutationRequestLifecycleDecisionType.Approved,
65+
MutationContext.System("governance-runtime", "Approved by governance"));
66+
```
67+
68+
The category-specific helpers are:
69+
70+
- `MutationRequestDecision.Lifecycle(...)`
71+
- `MutationRequestDecision.Approval(...)`
72+
- `MutationRequestDecision.VersionResolution(...)`
73+
74+
Use the low-level `MutationRequestDecision.Create(...)` only when the decision type is already dynamic and you do not know the category up front.
75+
76+
### Decision categories
77+
78+
- lifecycle decisions describe request state transitions such as submitted, pending, approved, rejected, executed, canceled, or expired
79+
- approval decisions describe workflow-level approval actions such as requested, approved, rejected, or quorum satisfied
80+
- version-resolution decisions describe how the runtime handled stale or matching versions before execution
81+
82+
The helper methods exist so call sites stay readable when the category is already known.
83+
84+
## Governed execution
85+
86+
Use `IGovernanceExecutionManager.ExecuteApproved(...)` when the runtime should resolve an approved request and execute the mutation:
87+
88+
```csharp
89+
var execution = await executionManager.ExecuteApproved(
90+
request.RequestId,
91+
mutation,
92+
currentState,
93+
governanceContext: MutationContext.Service("governance-runtime", "Execute approved governance request"),
94+
strategy: VersionedRequestResolutionStrategy.RejectStale);
95+
```
96+
97+
If your state implements `IVersionedState`, use the overload that accepts `currentState` directly and reads `state.Version` for both version selectors.
98+
99+
### Typical execution flow
100+
101+
1. create or load a request
102+
2. approve the request if needed
103+
3. resolve the request against the current state version
104+
4. execute the underlying mutation through the core engine
105+
5. record the terminal request decision
106+
107+
That flow is what the `GovernedExecution` example demonstrates end to end.
108+
109+
## Common operations
110+
111+
### Create a pending approval request
112+
113+
```csharp
114+
var request = MutationRequestFactory.PendingApproval(
115+
stateId: "tenant-42:roles",
116+
stateType: "IamRoleState",
117+
mutationType: "GrantRoleMutation",
118+
intent: new MutationIntent
119+
{
120+
OperationName = "GrantRole",
121+
Category = "Security",
122+
Description = "Grant elevated role to tenant operator"
123+
},
124+
context: MutationContext.User("requester-1", "Requester One", "Incident escalation"),
125+
expectedStateVersion: "v10",
126+
approvalRequirements:
127+
[
128+
MutationApprovalRequirement.SingleActorStep("security-lead"),
129+
MutationApprovalRequirement.SingleActorStep("platform-owner")
130+
]);
131+
```
132+
133+
### Record an approval decision
134+
135+
```csharp
136+
var decision = MutationRequestDecision.Approval(
137+
MutationRequestApprovalDecisionType.Approved,
138+
MutationContext.User("approver-1", "Approver One", "Approved after review"));
139+
```
140+
141+
### Execute an already approved request
142+
143+
```csharp
144+
var result = await executionManager.ExecuteApproved(
145+
request.RequestId,
146+
mutation,
147+
currentState,
148+
governanceContext: MutationContext.Service("governance-runtime", "Execute approved governance request"),
149+
strategy: VersionedRequestResolutionStrategy.RejectStale);
150+
```
151+
152+
## Where to look next
153+
154+
- `README.md` for the package overview
155+
- `Examples/Governance/*/README.md` for runnable scenarios
156+
- `src/Governance/Abstractions/Requests/Factory/MutationRequestFactory.cs` for request creation
157+
- `src/Governance/Abstractions/Requests/Decisions/MutationRequestDecision.cs` for decision helpers
158+
- `src/Governance/Runtime/Execution/Orchestration/GovernanceExecutionManager.cs` for governed execution

Docs/API/Redis.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Redis Governance Provider API
2+
3+
This page covers the Redis-backed provider package `ModularityKit.Mutator.Governance.Redis`.
4+
5+
It is the storage and query backend for governed requests. The package implements the public
6+
request store and query store contracts while keeping Redis-specific persistence details internal.
7+
8+
## What this API is for
9+
10+
Use the Redis provider when you want the governance model from `ModularityKit.Mutator.Governance`
11+
but need a persistent backing store instead of in-memory state.
12+
13+
Typical reasons:
14+
15+
- you want request persistence across process restarts
16+
- you want Redis-backed query projections for approval queues and decision history
17+
- you want the same governance API surface with a different storage backend
18+
- you want to share governed request state across multiple runtime instances
19+
20+
## Primary APIs
21+
22+
### Dependency injection
23+
24+
- `RedisGovernanceServiceCollectionExtensions.AddRedisGovernanceStore(...)`
25+
26+
Registers the Redis-backed governance store and the query store against an existing
27+
`IConnectionMultiplexer`.
28+
29+
```csharp
30+
using Microsoft.Extensions.DependencyInjection;
31+
using ModularityKit.Mutator.Governance.Redis;
32+
using StackExchange.Redis;
33+
34+
var services = new ServiceCollection();
35+
var multiplexer = await ConnectionMultiplexer.ConnectAsync("localhost:6379");
36+
37+
services.AddRedisGovernanceStore(
38+
multiplexer,
39+
options => options.KeyPrefix = "modularitykit:governance");
40+
```
41+
42+
The extension method registers the provider-facing storage and query services against DI.
43+
44+
### Configuration details
45+
46+
`RedisMutationRequestStoreOptions.KeyPrefix` scopes all Redis keys created by the provider.
47+
48+
Use a dedicated prefix per environment or application if you need isolation between deployments.
49+
The examples use `modularitykit:governance` as a readable default.
50+
51+
### Configuration
52+
53+
- `RedisMutationRequestStoreOptions`
54+
55+
Current option:
56+
57+
- `KeyPrefix` - Redis key prefix used for all request data, indexes, and query projections
58+
59+
### Storage facade
60+
61+
- `RedisMutationRequestStore`
62+
63+
Implements:
64+
65+
- `IMutationRequestStore`
66+
- `IMutationRequestQueryStore`
67+
68+
Use it through DI rather than constructing it directly unless you are testing internals.
69+
70+
### What gets stored
71+
72+
The provider stores the data needed for:
73+
74+
- request documents
75+
- decision and approval projections
76+
- query indexes and queue membership
77+
- optimistic concurrency state for request writes
78+
79+
The provider does not change the governance model. It only persists the same model through Redis.
80+
81+
### Keyspace and key helpers
82+
83+
- `RedisMutationRequestKeyspace`
84+
- `RedisMutationRequestDocumentKeyFactory`
85+
86+
These types define the Redis naming and partitioning rules for request documents, indexes, and
87+
query views.
88+
89+
## What the provider does
90+
91+
The Redis provider handles:
92+
93+
- request persistence
94+
- query projection
95+
- optimistic concurrency on request writes
96+
- Redis index maintenance
97+
- candidate selection for query execution
98+
99+
It does not change the governance model itself. It only replaces the in-memory storage backend with
100+
Redis-specific persistence and read paths.
101+
102+
## Common usage
103+
104+
### Register the provider
105+
106+
```csharp
107+
services.AddRedisGovernanceStore(
108+
multiplexer,
109+
options => options.KeyPrefix = "modularitykit:governance");
110+
```
111+
112+
### Resolve the stores
113+
114+
```csharp
115+
var requestStore = provider.GetRequiredService<IMutationRequestStore>();
116+
var queryStore = provider.GetRequiredService<IMutationRequestQueryStore>();
117+
```
118+
119+
### Use the normal governance APIs
120+
121+
Once registered, the rest of the application uses the same request and query contracts as the
122+
in-memory provider.
123+
124+
## Usage pattern
125+
126+
1. Connect to Redis with `StackExchange.Redis`
127+
2. Register the provider with `AddRedisGovernanceStore(...)`
128+
3. Resolve `IMutationRequestStore` and `IMutationRequestQueryStore` from DI
129+
4. Use the normal governance request and query APIs
130+
131+
## Related docs
132+
133+
- [`src/Redis/README.md`](../../src/Redis/README.md)
134+
- [`Examples/Governance/RedisQueries/README.md`](../../Examples/Governance/RedisQueries/README.md)
135+
- [`API-Reference.md`](API-Reference.md)
136+
- [Governance API](API.md)

Examples/Core/BillingQuotas/Mutations/DecreaseQuotaMutation.cs

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,23 @@ namespace BillingQuotas.Mutations;
1010
/// <summary>
1111
/// Mutation that decreases the quota for specific user by specified amount.
1212
/// </summary>
13-
internal sealed record DecreaseQuotaMutation(
14-
string UserId,
15-
int Amount,
16-
MutationContext Context
17-
) : IMutation<QuotaState>
13+
internal sealed class DecreaseQuotaMutation(
14+
string userId,
15+
int amount,
16+
MutationContext context
17+
) : MutationBase<QuotaState>(
18+
CreateIntent(
19+
operationName: "DecreaseQuota",
20+
category: "Billing",
21+
description: "Decrease user quota by given amount",
22+
riskLevel: MutationRiskLevel.High),
23+
context)
1824
{
19-
public MutationIntent Intent { get; } = new()
20-
{
21-
OperationName = "DecreaseQuota",
22-
Category = "Billing",
23-
RiskLevel = MutationRiskLevel.High,
24-
Description = "Decrease user quota by given amount"
25-
};
25+
public string UserId { get; } = userId;
26+
27+
public int Amount { get; } = amount;
2628

27-
public ValidationResult Validate(QuotaState state)
29+
public override ValidationResult Validate(QuotaState state)
2830
{
2931
var result = new ValidationResult();
3032

@@ -40,19 +42,15 @@ public ValidationResult Validate(QuotaState state)
4042
return result;
4143
}
4244

43-
public MutationResult<QuotaState> Apply(QuotaState state)
45+
public override MutationResult<QuotaState> Apply(QuotaState state)
4446
{
4547
var quotas = state.UserQuotas.ToDictionary(kv => kv.Key, kv => kv.Value);
4648
quotas[UserId] = quotas.GetValueOrDefault(UserId) - Amount;
4749

4850
var newState = state with { UserQuotas = quotas };
4951

50-
var changes = ChangeSet.Single(
51-
StateChange.Modified($"UserQuotas.{UserId}", null, quotas[UserId])
52-
);
53-
54-
return MutationResult<QuotaState>.Success(newState, changes);
52+
return Success(
53+
newState,
54+
StateChange.Modified($"UserQuotas.{UserId}", null, quotas[UserId]));
5555
}
56-
57-
public MutationResult<QuotaState> Simulate(QuotaState state) => Apply(state);
58-
}
56+
}

0 commit comments

Comments
 (0)