Skip to content

Commit 89ba695

Browse files
authored
Refine governance runtime and request lifecycle (#48)
# Summary This PR expands the governance runtime by introducing governed execution abstractions, refining the request model, and restructuring lifecycle handling. It also improves the internal testing infrastructure and updates the examples to reflect the new API. ## Highlights ### Governed execution * Add governed execution abstraction models. * Refine the governance execution runtime. * Document runtime resolution helpers. * Document the governed execution compensation model. ### Request model * Refine governance request abstractions. * Introduce a dedicated `MutationRequest.Lifecycle` object. * Move lifecycle related properties into the lifecycle model to improve cohesion and simplify the public API. ### Testing * Reorganize shared runtime test fixtures. * Reorganize governance and Redis provider test fixtures for focused scenarios. * Extract Redis-specific keyspace and serializer support where it reduces repetition. * Extract reusable runtime policy test support. * Extract strongly typed side-effect test payloads. ### Examples * Update governance examples to use the new request model and lifecycle structure. ## Why As the governance layer continues to evolve, the original request model became increasingly difficult to extend. Grouping lifecycle information into a dedicated object provides a cleaner API while making future governance capabilities easier to implement. This refactoring also prepares the runtime for richer execution scenarios by: * separating execution concerns from request representation, * improving the organization of lifecycle state, * reducing duplication across the test suite, * documenting the execution and compensation model, * providing clearer extension points for future governance providers. ## Breaking Changes `MutationRequest` has been updated. Lifecycle related members are now exposed through the `Lifecycle` object instead of being located directly on the request. Examples and tests have been updated accordingly. ## Closes * Closes #25 * Closes #42 * Closes #44 * Closes #45 * Closes #46 * Closes #47
2 parents bf9a94b + f557860 commit 89ba695

95 files changed

Lines changed: 3530 additions & 1923 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: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# ADR-034: Governed Execution Compensation Model
2+
3+
## Tag
4+
#adr_034
5+
6+
## Status
7+
Accepted
8+
9+
## Date
10+
2026-06-30
11+
12+
## Scope
13+
ModularityKit.Mutator.Governance
14+
15+
## Context
16+
17+
ADR-027 established governed execution as the point where approved governance requests enter the core mutation engine.
18+
19+
That execution path already persisted terminal request outcomes and already carried audit and history metadata, but it still lacked a first-class way to represent compensation:
20+
21+
- `MutationIntent.IsReversible` existed only as intent metadata
22+
- rollback or corrective compensation had no durable request-level model
23+
- original and compensating executions were not explicitly linked
24+
- audit and history could show that a mutation happened, but not that a later governed execution compensated for it
25+
26+
Without an explicit compensation model, governed execution would treat rollback and forward correction as informal follow-up work. That is not sufficient once governance requests, request history, and durable execution outcomes become part of the public platform model.
27+
28+
## Decision
29+
30+
Governed compensation should be modeled as a first-class governed execution concern.
31+
32+
The model should introduce:
33+
34+
- explicit compensation plan contracts
35+
- explicit execution classification for standard and compensating governed executions
36+
- explicit links between original and compensating request records
37+
- explicit request decision history for compensation behavior
38+
- explicit audit and history metadata that preserves compensation semantics
39+
40+
Implemented shape:
41+
42+
- `GovernedCompensationPlan` describes the original request being compensated together with compensation kind, trigger, optional batch identity, and related request identifiers
43+
- `GovernedCompensationKind` distinguishes restoration-style rollback from forward corrective compensation
44+
- `GovernedCompensationTrigger` distinguishes operator-driven rollback from batch or failed-execution initiated compensation
45+
- `GovernedExecutionKind` classifies governed execution as standard execution or compensation
46+
- `GovernedExecutionLink` and `GovernedExecutionLinkType` link governed requests through `Compensates` and `CompensatedBy`
47+
- request-level execution metadata is grouped under `GovernedExecutionDetails`
48+
- compensation requests are created through dedicated request factory flow rather than being mixed into the baseline request factory path
49+
- original requests receive a `Compensated` lifecycle decision when a compensating request executes successfully
50+
51+
Runtime behavior:
52+
53+
- compensation remains governed execution, not a utility outside the governance lifecycle
54+
- successful compensating execution updates the compensating request and then links it back to the original request
55+
- audit and history metadata must carry both execution kind and compensation metadata so the compensating behavior remains visible after persistence
56+
57+
## Design Rationale
58+
59+
- Compensation is a governance concern because it changes how durable requests, decisions, and execution outcomes are interpreted over time.
60+
- Explicit links are preferable to inference from tags or metadata because they survive storage, querying, and future provider implementations more cleanly.
61+
- Rollback and forward correction are not the same operational action, so the model should distinguish them even if the first tested path focuses on rollback.
62+
- A dedicated compensation request factory keeps baseline request creation simpler while allowing compensation-specific semantics to remain explicit.
63+
- Audit and history already form part of the governed execution contract, so compensation semantics should extend those flows rather than introducing a parallel reporting mechanism.
64+
65+
## Consequences
66+
67+
### Positive
68+
69+
- Governed execution now has durable compensation semantics instead of relying on convention.
70+
- Original and compensating requests can be traversed explicitly.
71+
- Audit, history, and request decision flows preserve why and how compensation happened.
72+
- The model supports operator-driven rollback immediately and leaves room for broader compensation planning later.
73+
74+
### Negative
75+
76+
- The governance request model becomes more complex because execution metadata now carries compensation and linking semantics.
77+
- Runtime execution must perform an additional persistence step to link successful compensation back to the original request.
78+
- Future storage providers and query models must preserve the new compensation and linking fields consistently.
79+
80+
## Non-Goals
81+
82+
- automatic generation of reverse mutations
83+
- distributed saga orchestration
84+
- full batch compensation engine
85+
- replacing explicit domain modeling for inherently irreversible mutations
86+
87+
## Related ADRs
88+
89+
- ADR-020: Governance MutationRequest Model
90+
- ADR-022: Governance Request Decisions and Storage
91+
- ADR-023: Governance Versioned Request Resolution
92+
- ADR-027: Governed Execution Manager

Docs/Decision/listadr.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,6 @@ These ADRs describe the `ModularityKit.Mutator.Governance` extension layer and i
4949
| ADR-031 | Governance Redis Serialization and Document Compatibility | [ADR-031](Adr/ADR_031_Governance_Redis_Serialization_and_Document_Compatibility.md) |
5050
| ADR-032 | Governance Redis Concurrency and Index Maintenance Model | [ADR-032](Adr/ADR_032_Governance_Redis_Concurrency_and_Index_Maintenance_Model.md) |
5151
| ADR-033 | Governance Query Model Decomposition | [ADR-033](Adr/ADR_033_Governance_Query_Model_Decomposition.md) |
52+
| ADR-034 | Governed Execution Compensation Model | [ADR-034](Adr/ADR_034_Governed_Execution_Compensation_Model.md) |
5253

5354
> See individual ADRs for detailed context, decision rationale, and consequences.

Examples/Governance/ApprovalWorkflow/Scenarios/GovernanceApprovalWorkflowScenario.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@ private static void PrintSection(string title)
151151

152152
private static void PrintRequest(MutationRequest request)
153153
{
154-
Console.WriteLine($"Request status: {request.Status}");
155-
Console.WriteLine($"Pending reason: {request.PendingReason?.ToString() ?? "-"}");
154+
Console.WriteLine($"Request status: {request.Lifecycle.Status}");
155+
Console.WriteLine($"Pending reason: {request.Lifecycle.PendingReason?.ToString() ?? "-"}");
156156
Console.WriteLine($"Revision: {request.Revision}");
157157
Console.WriteLine("Approval requirements:");
158158

Examples/Governance/GovernedExecution/Scenarios/GovernanceExecutionScenario.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public static async Task Run()
4141
expectedStateVersion: "v10"));
4242

4343
var currentState = new FeatureFlagState(
44-
request.StateId,
44+
request.Scope.StateId,
4545
IsEnabled: false,
4646
Version: "v10");
4747

@@ -55,7 +55,7 @@ public static async Task Run()
5555
Console.WriteLine("=== Governed Execution ===");
5656
Console.WriteLine($"Executed: {execution.WasExecuted}");
5757
Console.WriteLine($"Resolution: {execution.Resolution.Outcome}");
58-
Console.WriteLine($"Request status: {execution.Request.Status}");
58+
Console.WriteLine($"Request status: {execution.Request.Lifecycle.Status}");
5959
Console.WriteLine($"Resulting version: {execution.ResultingStateVersion ?? "-"}");
6060
Console.WriteLine($"Last decision: {execution.Request.Decisions[^1].Type}");
6161
Console.WriteLine($"Reason: {execution.Request.Decisions[^1].Reason}");

Examples/Governance/Queries/Scenarios/GovernanceQueriesSampleData.cs

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public static void PrintRequests(IReadOnlyList<MutationRequest> requests)
7272
foreach (var request in requests)
7373
{
7474
Console.WriteLine(
75-
$"- {request.RequestId} | {request.StateId} | {request.Intent.Category} | {request.Status} | pending: {request.PendingReason?.ToString() ?? "-"}");
75+
$"- {request.RequestId} | {request.Scope.StateId} | {request.Payload.Intent.Category} | {request.Lifecycle.Status} | pending: {request.Lifecycle.PendingReason?.ToString() ?? "-"}");
7676
}
7777

7878
if (requests.Count == 0)
@@ -84,7 +84,7 @@ public static void PrintApprovals(IReadOnlyList<MutationApprovalView> approvals)
8484
foreach (var approval in approvals)
8585
{
8686
Console.WriteLine(
87-
$"- {approval.Request.RequestId} | {approval.Request.Intent.Category} | approver: {approval.Approval.ApproverId} | status: {approval.Approval.Status}");
87+
$"- {approval.Request.RequestId} | {approval.Request.Payload.Intent.Category} | approver: {approval.Approval.ApproverId} | status: {approval.Approval.Status}");
8888
}
8989

9090
if (approvals.Count == 0)
@@ -133,8 +133,13 @@ private static MutationRequest CreatePendingApprovalRequest(
133133
with
134134
{
135135
RequestId = requestId,
136-
CreatedAt = createdAt,
137-
UpdatedAt = createdAt,
136+
Lifecycle = new MutationRequestLifecycleDetails
137+
{
138+
Status = MutationRequestStatus.Pending,
139+
PendingReason = PendingMutationReason.Approval,
140+
CreatedAt = createdAt,
141+
UpdatedAt = createdAt
142+
},
138143
Metadata = new Dictionary<string, object>
139144
{
140145
["ticket"] = category == "Security" ? "INC-42" : "BILL-7"
@@ -176,8 +181,13 @@ private static MutationRequest CreateExternalCheckRequest(
176181
with
177182
{
178183
RequestId = requestId,
179-
CreatedAt = createdAt,
180-
UpdatedAt = createdAt,
184+
Lifecycle = new MutationRequestLifecycleDetails
185+
{
186+
Status = MutationRequestStatus.Pending,
187+
PendingReason = PendingMutationReason.ExternalCheck,
188+
CreatedAt = createdAt,
189+
UpdatedAt = createdAt
190+
},
181191
Metadata = new Dictionary<string, object>
182192
{
183193
["ticket"] = "REL-99"
@@ -214,10 +224,13 @@ private static MutationRequest CreateRecentlyApprovedRequest(
214224
with
215225
{
216226
RequestId = requestId,
217-
Status = MutationRequestStatus.Approved,
218-
PendingReason = null,
219-
CreatedAt = approvedAt.AddMinutes(-20),
220-
UpdatedAt = approvedAt,
227+
Lifecycle = new MutationRequestLifecycleDetails
228+
{
229+
Status = MutationRequestStatus.Approved,
230+
PendingReason = null,
231+
CreatedAt = approvedAt.AddMinutes(-20),
232+
UpdatedAt = approvedAt
233+
},
221234
Metadata = new Dictionary<string, object>
222235
{
223236
["ticket"] = category == "Security" ? "INC-77" : "BILL-9"
@@ -297,9 +310,12 @@ private static MutationRequest CreateResolvedRequest(
297310
with
298311
{
299312
RequestId = requestId,
300-
Status = MutationRequestStatus.Approved,
301-
CreatedAt = decisionTimestamp.AddMinutes(-30),
302-
UpdatedAt = decisionTimestamp,
313+
Lifecycle = new MutationRequestLifecycleDetails
314+
{
315+
Status = MutationRequestStatus.Approved,
316+
CreatedAt = decisionTimestamp.AddMinutes(-30),
317+
UpdatedAt = decisionTimestamp
318+
},
303319
Metadata = new Dictionary<string, object>
304320
{
305321
["ticket"] = "CFG-5"
@@ -348,10 +364,16 @@ private static MutationRequest CreateExecutedRequest(
348364
with
349365
{
350366
RequestId = requestId,
351-
Status = MutationRequestStatus.Executed,
352-
CreatedAt = executedAt.AddMinutes(-15),
353-
UpdatedAt = executedAt,
354-
ExecutedAt = executedAt,
367+
Lifecycle = new MutationRequestLifecycleDetails
368+
{
369+
Status = MutationRequestStatus.Executed,
370+
CreatedAt = executedAt.AddMinutes(-15),
371+
UpdatedAt = executedAt
372+
},
373+
Versioning = new MutationRequestVersioningDetails
374+
{
375+
ExecutedAt = executedAt
376+
},
355377
SideEffects =
356378
[
357379
SideEffect.Critical(

Examples/Governance/RedisQueries/Scenarios/GovernanceRedisQueriesScenario.cs

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ public static async Task Run()
6969
}
7070
catch (RedisConnectionException exception)
7171
{
72-
Console.Error.WriteLine($"Could not connect to Redis at '{redisConnectionString}'.");
73-
Console.Error.WriteLine(exception.Message);
74-
Console.Error.WriteLine("Start Redis locally or set MODULARITYKIT_REDIS to a reachable endpoint.");
72+
await Console.Error.WriteLineAsync($"Could not connect to Redis at '{redisConnectionString}'.");
73+
await Console.Error.WriteLineAsync(exception.Message);
74+
await Console.Error.WriteLineAsync("Start Redis locally or set MODULARITYKIT_REDIS to a reachable endpoint.");
7575
}
7676
}
7777

@@ -137,8 +137,13 @@ private static MutationRequest CreatePendingApprovalRequest(
137137
with
138138
{
139139
RequestId = requestId,
140-
CreatedAt = createdAt,
141-
UpdatedAt = createdAt
140+
Lifecycle = new MutationRequestLifecycleDetails
141+
{
142+
Status = MutationRequestStatus.Pending,
143+
PendingReason = PendingMutationReason.Approval,
144+
CreatedAt = createdAt,
145+
UpdatedAt = createdAt
146+
}
142147
};
143148

144149
private static MutationRequest CreateExecutedRequest(
@@ -160,10 +165,16 @@ private static MutationRequest CreateExecutedRequest(
160165
with
161166
{
162167
RequestId = requestId,
163-
Status = MutationRequestStatus.Executed,
164-
CreatedAt = executedAt.AddMinutes(-15),
165-
UpdatedAt = executedAt,
166-
ExecutedAt = executedAt,
168+
Lifecycle = new MutationRequestLifecycleDetails
169+
{
170+
Status = MutationRequestStatus.Executed,
171+
CreatedAt = executedAt.AddMinutes(-15),
172+
UpdatedAt = executedAt
173+
},
174+
Versioning = new MutationRequestVersioningDetails
175+
{
176+
ExecutedAt = executedAt
177+
},
167178
SideEffects =
168179
[
169180
SideEffect.Critical(
@@ -200,7 +211,7 @@ private static MutationRequest CreateExecutedRequest(
200211
]
201212
};
202213

203-
[SideEffectDataContract("examples.redis.execution-side-effect", 1)]
214+
[SideEffectDataContract("examples.redis.execution-side-effect")]
204215
private sealed record RedisExecutionSideEffectData
205216
{
206217
public required string Ticket { get; init; }
@@ -217,7 +228,7 @@ private static void PrintRequests(IReadOnlyList<MutationRequest> requests)
217228
foreach (var request in requests)
218229
{
219230
Console.WriteLine(
220-
$"- {request.RequestId} | {request.StateId} | {request.Intent.Category} | {request.Status} | pending: {request.PendingReason?.ToString() ?? "-"}");
231+
$"- {request.RequestId} | {request.Scope.StateId} | {request.Payload.Intent.Category} | {request.Lifecycle.Status} | pending: {request.Lifecycle.PendingReason?.ToString() ?? "-"}");
221232
}
222233

223234
if (requests.Count == 0)
@@ -229,7 +240,7 @@ private static void PrintApprovals(IReadOnlyList<MutationApprovalView> approvals
229240
foreach (var approval in approvals)
230241
{
231242
Console.WriteLine(
232-
$"- {approval.Request.RequestId} | {approval.Request.Intent.Category} | approver: {approval.Approval.ApproverId} | status: {approval.Approval.Status}");
243+
$"- {approval.Request.RequestId} | {approval.Request.Payload.Intent.Category} | approver: {approval.Approval.ApproverId} | status: {approval.Approval.Status}");
233244
}
234245

235246
if (approvals.Count == 0)

Examples/Governance/RequestLifecycle/Scenarios/GovernanceRequestLifecycleScenario.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ private static void PrintRequests(IReadOnlyList<MutationRequest> requests)
119119
foreach (var request in requests)
120120
{
121121
Console.WriteLine(
122-
$"- {request.RequestId} | {request.StateId} | {request.Status} | pending: {request.PendingReason?.ToString() ?? "-"}");
122+
$"- {request.RequestId} | {request.Scope.StateId} | {request.Lifecycle.Status} | pending: {request.Lifecycle.PendingReason?.ToString() ?? "-"}");
123123
}
124124

125125
if (requests.Count == 0)
@@ -129,10 +129,10 @@ private static void PrintRequests(IReadOnlyList<MutationRequest> requests)
129129
private static void PrintRequestDetails(MutationRequest request)
130130
{
131131
Console.WriteLine($"{request.RequestId}");
132-
Console.WriteLine($" state: {request.StateId}");
133-
Console.WriteLine($" status: {request.Status}");
134-
Console.WriteLine($" pending: {request.PendingReason?.ToString() ?? "-"}");
135-
Console.WriteLine($" expires: {request.ExpiresAt?.ToString("O") ?? "-"}");
132+
Console.WriteLine($" state: {request.Scope.StateId}");
133+
Console.WriteLine($" status: {request.Lifecycle.Status}");
134+
Console.WriteLine($" pending: {request.Lifecycle.PendingReason?.ToString() ?? "-"}");
135+
Console.WriteLine($" expires: {request.Lifecycle.ExpiresAt?.ToString("O") ?? "-"}");
136136
Console.WriteLine(" decisions:");
137137

138138
foreach (var decision in request.Decisions)

Examples/Governance/VersionedResolution/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ var resolution = await manager.ResolveAndStore(
5757
strategy: VersionedRequestResolutionStrategy.RejectStale);
5858

5959
Console.WriteLine(resolution.Outcome);
60-
Console.WriteLine(resolution.Request.Status);
60+
Console.WriteLine(resolution.Request.Lifecycle.Status);
6161
Console.WriteLine(resolution.Request.Decisions[^1].Type);
6262
```
6363

Examples/Governance/VersionedResolution/Scenarios/GovernanceVersionedResolutionScenario.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ private static void PrintResolution(MutationRequestVersionResolution resolution)
9191
Console.WriteLine($"Was stale: {resolution.IsStale}");
9292
Console.WriteLine($"Expected version: {resolution.ExpectedStateVersion ?? "-"}");
9393
Console.WriteLine($"Current version: {resolution.CurrentStateVersion}");
94-
Console.WriteLine($"Request status: {resolution.Request.Status}");
95-
Console.WriteLine($"Next expected version: {resolution.Request.ExpectedStateVersion ?? "-"}");
94+
Console.WriteLine($"Request status: {resolution.Request.Lifecycle.Status}");
95+
Console.WriteLine($"Next expected version: {resolution.Request.Versioning.ExpectedStateVersion ?? "-"}");
9696
Console.WriteLine($"Last decision: {decision.Type}");
9797
Console.WriteLine($"Decision reason: {decision.Reason}");
9898
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
2+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
3+
using ModularityKit.Mutator.Governance.Redis.Tests.TestSupport.Keys;
4+
using Xunit;
5+
6+
namespace ModularityKit.Mutator.Governance.Redis.Tests.Keys;
7+
8+
public sealed partial class RedisMutationRequestKeyspaceTests
9+
{
10+
[Fact]
11+
public void Enumerate_indexes_includes_pending_indexes_only_for_pending_requests()
12+
{
13+
var keyspace = RedisMutationRequestKeyspaceTestSupport.CreateKeyspace();
14+
15+
var request = new MutationRequest
16+
{
17+
RequestId = "req-42",
18+
Scope = new MutationRequestScopeDetails
19+
{
20+
StateId = "tenant-42",
21+
StateType = "IamRoleState",
22+
MutationType = "GrantRoleMutation"
23+
},
24+
Lifecycle = new MutationRequestLifecycleDetails
25+
{
26+
Status = MutationRequestStatus.Pending,
27+
PendingReason = PendingMutationReason.Approval
28+
}
29+
};
30+
31+
var keys = keyspace.EnumerateIndexes(request).Select(key => key.ToString()).ToArray();
32+
33+
Assert.Contains("mk:gov:requests:ids", keys);
34+
Assert.Contains("mk:gov:states:tenant-42:requests", keys);
35+
Assert.Contains("mk:gov:status:pending:requests", keys);
36+
Assert.Contains("mk:gov:pending:requests", keys);
37+
Assert.Contains("mk:gov:pending:approval:requests", keys);
38+
}
39+
}

0 commit comments

Comments
 (0)