Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions Docs/Decision/Adr/ADR_034_Governed_Execution_Compensation_Model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# ADR-034: Governed Execution Compensation Model

## Tag
#adr_034

## Status
Accepted

## Date
2026-06-30

## Scope
ModularityKit.Mutator.Governance

## Context

ADR-027 established governed execution as the point where approved governance requests enter the core mutation engine.

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:

- `MutationIntent.IsReversible` existed only as intent metadata
- rollback or corrective compensation had no durable request-level model
- original and compensating executions were not explicitly linked
- audit and history could show that a mutation happened, but not that a later governed execution compensated for it

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.

## Decision

Governed compensation should be modeled as a first-class governed execution concern.

The model should introduce:

- explicit compensation plan contracts
- explicit execution classification for standard and compensating governed executions
- explicit links between original and compensating request records
- explicit request decision history for compensation behavior
- explicit audit and history metadata that preserves compensation semantics

Implemented shape:

- `GovernedCompensationPlan` describes the original request being compensated together with compensation kind, trigger, optional batch identity, and related request identifiers
- `GovernedCompensationKind` distinguishes restoration-style rollback from forward corrective compensation
- `GovernedCompensationTrigger` distinguishes operator-driven rollback from batch or failed-execution initiated compensation
- `GovernedExecutionKind` classifies governed execution as standard execution or compensation
- `GovernedExecutionLink` and `GovernedExecutionLinkType` link governed requests through `Compensates` and `CompensatedBy`
- request-level execution metadata is grouped under `GovernedExecutionDetails`
- compensation requests are created through dedicated request factory flow rather than being mixed into the baseline request factory path
- original requests receive a `Compensated` lifecycle decision when a compensating request executes successfully

Runtime behavior:

- compensation remains governed execution, not a utility outside the governance lifecycle
- successful compensating execution updates the compensating request and then links it back to the original request
- audit and history metadata must carry both execution kind and compensation metadata so the compensating behavior remains visible after persistence

## Design Rationale

- Compensation is a governance concern because it changes how durable requests, decisions, and execution outcomes are interpreted over time.
- Explicit links are preferable to inference from tags or metadata because they survive storage, querying, and future provider implementations more cleanly.
- 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.
- A dedicated compensation request factory keeps baseline request creation simpler while allowing compensation-specific semantics to remain explicit.
- 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.

## Consequences

### Positive

- Governed execution now has durable compensation semantics instead of relying on convention.
- Original and compensating requests can be traversed explicitly.
- Audit, history, and request decision flows preserve why and how compensation happened.
- The model supports operator-driven rollback immediately and leaves room for broader compensation planning later.

### Negative

- The governance request model becomes more complex because execution metadata now carries compensation and linking semantics.
- Runtime execution must perform an additional persistence step to link successful compensation back to the original request.
- Future storage providers and query models must preserve the new compensation and linking fields consistently.

## Non-Goals

- automatic generation of reverse mutations
- distributed saga orchestration
- full batch compensation engine
- replacing explicit domain modeling for inherently irreversible mutations

## Related ADRs

- ADR-020: Governance MutationRequest Model
- ADR-022: Governance Request Decisions and Storage
- ADR-023: Governance Versioned Request Resolution
- ADR-027: Governed Execution Manager
1 change: 1 addition & 0 deletions Docs/Decision/listadr.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,6 @@ These ADRs describe the `ModularityKit.Mutator.Governance` extension layer and i
| ADR-031 | Governance Redis Serialization and Document Compatibility | [ADR-031](Adr/ADR_031_Governance_Redis_Serialization_and_Document_Compatibility.md) |
| ADR-032 | Governance Redis Concurrency and Index Maintenance Model | [ADR-032](Adr/ADR_032_Governance_Redis_Concurrency_and_Index_Maintenance_Model.md) |
| ADR-033 | Governance Query Model Decomposition | [ADR-033](Adr/ADR_033_Governance_Query_Model_Decomposition.md) |
| ADR-034 | Governed Execution Compensation Model | [ADR-034](Adr/ADR_034_Governed_Execution_Compensation_Model.md) |

> See individual ADRs for detailed context, decision rationale, and consequences.
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ private static void PrintSection(string title)

private static void PrintRequest(MutationRequest request)
{
Console.WriteLine($"Request status: {request.Status}");
Console.WriteLine($"Pending reason: {request.PendingReason?.ToString() ?? "-"}");
Console.WriteLine($"Request status: {request.Lifecycle.Status}");
Console.WriteLine($"Pending reason: {request.Lifecycle.PendingReason?.ToString() ?? "-"}");
Console.WriteLine($"Revision: {request.Revision}");
Console.WriteLine("Approval requirements:");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public static async Task Run()
expectedStateVersion: "v10"));

var currentState = new FeatureFlagState(
request.StateId,
request.Scope.StateId,
IsEnabled: false,
Version: "v10");

Expand All @@ -55,7 +55,7 @@ public static async Task Run()
Console.WriteLine("=== Governed Execution ===");
Console.WriteLine($"Executed: {execution.WasExecuted}");
Console.WriteLine($"Resolution: {execution.Resolution.Outcome}");
Console.WriteLine($"Request status: {execution.Request.Status}");
Console.WriteLine($"Request status: {execution.Request.Lifecycle.Status}");
Console.WriteLine($"Resulting version: {execution.ResultingStateVersion ?? "-"}");
Console.WriteLine($"Last decision: {execution.Request.Decisions[^1].Type}");
Console.WriteLine($"Reason: {execution.Request.Decisions[^1].Reason}");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public static void PrintRequests(IReadOnlyList<MutationRequest> requests)
foreach (var request in requests)
{
Console.WriteLine(
$"- {request.RequestId} | {request.StateId} | {request.Intent.Category} | {request.Status} | pending: {request.PendingReason?.ToString() ?? "-"}");
$"- {request.RequestId} | {request.Scope.StateId} | {request.Payload.Intent.Category} | {request.Lifecycle.Status} | pending: {request.Lifecycle.PendingReason?.ToString() ?? "-"}");
}

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

if (approvals.Count == 0)
Expand Down Expand Up @@ -133,8 +133,13 @@ private static MutationRequest CreatePendingApprovalRequest(
with
{
RequestId = requestId,
CreatedAt = createdAt,
UpdatedAt = createdAt,
Lifecycle = new MutationRequestLifecycleDetails
{
Status = MutationRequestStatus.Pending,
PendingReason = PendingMutationReason.Approval,
CreatedAt = createdAt,
UpdatedAt = createdAt
},
Metadata = new Dictionary<string, object>
{
["ticket"] = category == "Security" ? "INC-42" : "BILL-7"
Expand Down Expand Up @@ -176,8 +181,13 @@ private static MutationRequest CreateExternalCheckRequest(
with
{
RequestId = requestId,
CreatedAt = createdAt,
UpdatedAt = createdAt,
Lifecycle = new MutationRequestLifecycleDetails
{
Status = MutationRequestStatus.Pending,
PendingReason = PendingMutationReason.ExternalCheck,
CreatedAt = createdAt,
UpdatedAt = createdAt
},
Metadata = new Dictionary<string, object>
{
["ticket"] = "REL-99"
Expand Down Expand Up @@ -214,10 +224,13 @@ private static MutationRequest CreateRecentlyApprovedRequest(
with
{
RequestId = requestId,
Status = MutationRequestStatus.Approved,
PendingReason = null,
CreatedAt = approvedAt.AddMinutes(-20),
UpdatedAt = approvedAt,
Lifecycle = new MutationRequestLifecycleDetails
{
Status = MutationRequestStatus.Approved,
PendingReason = null,
CreatedAt = approvedAt.AddMinutes(-20),
UpdatedAt = approvedAt
},
Metadata = new Dictionary<string, object>
{
["ticket"] = category == "Security" ? "INC-77" : "BILL-9"
Expand Down Expand Up @@ -297,9 +310,12 @@ private static MutationRequest CreateResolvedRequest(
with
{
RequestId = requestId,
Status = MutationRequestStatus.Approved,
CreatedAt = decisionTimestamp.AddMinutes(-30),
UpdatedAt = decisionTimestamp,
Lifecycle = new MutationRequestLifecycleDetails
{
Status = MutationRequestStatus.Approved,
CreatedAt = decisionTimestamp.AddMinutes(-30),
UpdatedAt = decisionTimestamp
},
Metadata = new Dictionary<string, object>
{
["ticket"] = "CFG-5"
Expand Down Expand Up @@ -348,10 +364,16 @@ private static MutationRequest CreateExecutedRequest(
with
{
RequestId = requestId,
Status = MutationRequestStatus.Executed,
CreatedAt = executedAt.AddMinutes(-15),
UpdatedAt = executedAt,
ExecutedAt = executedAt,
Lifecycle = new MutationRequestLifecycleDetails
{
Status = MutationRequestStatus.Executed,
CreatedAt = executedAt.AddMinutes(-15),
UpdatedAt = executedAt
},
Versioning = new MutationRequestVersioningDetails
{
ExecutedAt = executedAt
},
SideEffects =
[
SideEffect.Critical(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ public static async Task Run()
}
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.");
await Console.Error.WriteLineAsync($"Could not connect to Redis at '{redisConnectionString}'.");
await Console.Error.WriteLineAsync(exception.Message);
await Console.Error.WriteLineAsync("Start Redis locally or set MODULARITYKIT_REDIS to a reachable endpoint.");
}
}

Expand Down Expand Up @@ -137,8 +137,13 @@ private static MutationRequest CreatePendingApprovalRequest(
with
{
RequestId = requestId,
CreatedAt = createdAt,
UpdatedAt = createdAt
Lifecycle = new MutationRequestLifecycleDetails
{
Status = MutationRequestStatus.Pending,
PendingReason = PendingMutationReason.Approval,
CreatedAt = createdAt,
UpdatedAt = createdAt
}
};

private static MutationRequest CreateExecutedRequest(
Expand All @@ -160,10 +165,16 @@ private static MutationRequest CreateExecutedRequest(
with
{
RequestId = requestId,
Status = MutationRequestStatus.Executed,
CreatedAt = executedAt.AddMinutes(-15),
UpdatedAt = executedAt,
ExecutedAt = executedAt,
Lifecycle = new MutationRequestLifecycleDetails
{
Status = MutationRequestStatus.Executed,
CreatedAt = executedAt.AddMinutes(-15),
UpdatedAt = executedAt
},
Versioning = new MutationRequestVersioningDetails
{
ExecutedAt = executedAt
},
SideEffects =
[
SideEffect.Critical(
Expand Down Expand Up @@ -200,7 +211,7 @@ private static MutationRequest CreateExecutedRequest(
]
};

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

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

if (approvals.Count == 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ private static void PrintRequests(IReadOnlyList<MutationRequest> requests)
foreach (var request in requests)
{
Console.WriteLine(
$"- {request.RequestId} | {request.StateId} | {request.Status} | pending: {request.PendingReason?.ToString() ?? "-"}");
$"- {request.RequestId} | {request.Scope.StateId} | {request.Lifecycle.Status} | pending: {request.Lifecycle.PendingReason?.ToString() ?? "-"}");
}

if (requests.Count == 0)
Expand All @@ -129,10 +129,10 @@ private static void PrintRequests(IReadOnlyList<MutationRequest> requests)
private static void PrintRequestDetails(MutationRequest request)
{
Console.WriteLine($"{request.RequestId}");
Console.WriteLine($" state: {request.StateId}");
Console.WriteLine($" status: {request.Status}");
Console.WriteLine($" pending: {request.PendingReason?.ToString() ?? "-"}");
Console.WriteLine($" expires: {request.ExpiresAt?.ToString("O") ?? "-"}");
Console.WriteLine($" state: {request.Scope.StateId}");
Console.WriteLine($" status: {request.Lifecycle.Status}");
Console.WriteLine($" pending: {request.Lifecycle.PendingReason?.ToString() ?? "-"}");
Console.WriteLine($" expires: {request.Lifecycle.ExpiresAt?.ToString("O") ?? "-"}");
Console.WriteLine(" decisions:");

foreach (var decision in request.Decisions)
Expand Down
2 changes: 1 addition & 1 deletion Examples/Governance/VersionedResolution/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ var resolution = await manager.ResolveAndStore(
strategy: VersionedRequestResolutionStrategy.RejectStale);

Console.WriteLine(resolution.Outcome);
Console.WriteLine(resolution.Request.Status);
Console.WriteLine(resolution.Request.Lifecycle.Status);
Console.WriteLine(resolution.Request.Decisions[^1].Type);
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ private static void PrintResolution(MutationRequestVersionResolution resolution)
Console.WriteLine($"Was stale: {resolution.IsStale}");
Console.WriteLine($"Expected version: {resolution.ExpectedStateVersion ?? "-"}");
Console.WriteLine($"Current version: {resolution.CurrentStateVersion}");
Console.WriteLine($"Request status: {resolution.Request.Status}");
Console.WriteLine($"Next expected version: {resolution.Request.ExpectedStateVersion ?? "-"}");
Console.WriteLine($"Request status: {resolution.Request.Lifecycle.Status}");
Console.WriteLine($"Next expected version: {resolution.Request.Versioning.ExpectedStateVersion ?? "-"}");
Console.WriteLine($"Last decision: {decision.Type}");
Console.WriteLine($"Decision reason: {decision.Reason}");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
using ModularityKit.Mutator.Governance.Redis.Tests.TestSupport.Keys;
using Xunit;

namespace ModularityKit.Mutator.Governance.Redis.Tests.Keys;

public sealed partial class RedisMutationRequestKeyspaceTests
{
[Fact]
public void Enumerate_indexes_includes_pending_indexes_only_for_pending_requests()
{
var keyspace = RedisMutationRequestKeyspaceTestSupport.CreateKeyspace();

var request = new MutationRequest
{
RequestId = "req-42",
Scope = new MutationRequestScopeDetails
{
StateId = "tenant-42",
StateType = "IamRoleState",
MutationType = "GrantRoleMutation"
},
Lifecycle = new MutationRequestLifecycleDetails
{
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);
}
}
Loading
Loading