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
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# ADR-028: Governance Approval Workflow Hardening

## Tag
#adr_028

## Status
Accepted

## Date
2026-06-24

## Scope
ModularityKit.Mutator.Governance

## Context

The governance package already implements the first approval workflow:

- request-level approval requirements
- ordered step execution
- explicit approve and reject actions
- approval history recorded through `MutationRequestDecision`

That first slice is functional, but it is too narrow for operational governance scenarios.

The main gaps are:

- approval targets are mostly modeled as individual actors
- grouped approvals and quorum semantics are not first-class
- approval expiration is not modeled independently from generic request expiration
- rejection is recorded, but the business reason model is too thin
- some failure paths still collapse into generic invalid-operation behavior

Without hardening, governance approval would remain present but not expressive enough for real multi-actor approval workflows.

## Decision

The governance package hardens approval workflow semantics in the following way:

- approval requirements may target:
- a concrete approver id
- an approver role
- an approver group
- approval requirements may participate in approval groups with explicit quorum semantics such as `N-of-M`
- approval requirements may expire independently through approval-specific expiration timestamps
- rejection may carry a structured `MutationApprovalRejectionReason`
- approval-specific invalid states and invalid configurations should raise domain-specific approval exceptions

The request-centric governance model remains unchanged:

- approvals are still attached to a `MutationRequest`
- approval actions still resolve request-level approval requirements
- approval outcomes still become request decision history

Quorum behavior is modeled explicitly:

- once a grouped approval reaches the required quorum
- remaining pending approvals in that group become `Satisfied`
- request history records a `QuorumSatisfied` approval decision

Expiration behavior is also explicit:

- expired approval requirements become `Expired`
- expired approval sets reject the request through governance runtime
- request history records approval expiration and terminal request rejection

## Design Rationale

- Governance approvals must support real organizational approval patterns, not only linear actor-by-actor sign-off.
- Role and group targeting fits the existing `MutationContext` model without introducing a separate identity subsystem.
- Quorum semantics belong in governance approval, not in the core mutation engine.
- Structured rejection reasons make approval denial auditable and machine-readable.
- Approval expiration should be explicit because an approval timeout is not the same business event as generic request expiration.

## Consequences

### Positive

- Approval workflow now supports richer operational behavior without leaving the request-centric governance model.
- Multi-actor and grouped approvals become first-class.
- Approval denial and expiration become more auditable.
- Governance runtime can represent approval completion without requiring every approver in a quorum set to act.

### Negative

- Approval model complexity increases.
- Approval targeting now depends on agreed metadata conventions for actor roles and groups.
- Future persistence providers will need to store richer approval state and rejection metadata.

## Related ADRs

- ADR-025: Governance Approval Workflow
- 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 @@ -43,5 +43,6 @@ These ADRs describe the `ModularityKit.Mutator.Governance` extension layer and i
| ADR-025 | Governance Approval Workflow | [ADR-025](Adr/ADR_025_Governance_Approval_Workflow.md) |
| 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) |

> See individual ADRs for detailed context, decision rationale, and consequences.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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.Requests.Factory;
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
using ModularityKit.Mutator.Governance.Runtime.Approval.Execution;
Expand All @@ -27,21 +28,44 @@ public static async Task Run()
MutationContext.User("alice", "Alice", "Manager approved"));
PrintRequest(afterAlice);

PrintSection("Approve Step 1 - Second Actor");
PrintSection("Approve Step 2 - Quorum Approval 1/2");
var bobApproval = afterAlice.ApprovalRequirements.Single(requirement => requirement.ApproverId == "bob");
var afterBob = await manager.ApproveRequirement(
request.RequestId,
bobApproval.ApprovalId,
MutationContext.User("bob", "Bob", "Security approved"));
PrintRequest(afterBob);

PrintSection("Approve Step 2");
PrintSection("Approve Step 2 - Quorum Approval 2/2");
var carolApproval = afterBob.ApprovalRequirements.Single(requirement => requirement.ApproverId == "carol");
var afterCarol = await manager.ApproveRequirement(
request.RequestId,
carolApproval.ApprovalId,
MutationContext.User("carol", "Carol", "Finance approved"));
PrintRequest(afterCarol);

PrintSection("Approve Step 3 - Role Target");
var financeApproval = afterCarol.ApprovalRequirements.Single(requirement => requirement.ApproverRole == "finance-approver");
var afterFinance = await manager.ApproveRequirement(
request.RequestId,
financeApproval.ApprovalId,
MutationContext.User("frank", "Frank", "Finance role approved") with
{
Metadata = new Dictionary<string, object>
{
["ActorRoles"] = new[] { "finance-approver" }
}
});
PrintRequest(afterFinance);

PrintSection("Expire A Separate Pending Approval Request");
var expiringRequest = await store.Create(CreateExpiringApprovalRequest());
var expired = await manager.ExpirePendingApprovals(
DateTimeOffset.UtcNow,
MutationContext.Service("approval-timeout-monitor", "Expire stale approvals"));

var expiredRequest = expired.Single(candidate => candidate.RequestId == expiringRequest.RequestId);
PrintRequest(expiredRequest);
}

private static MutationRequest CreateApprovalRequest()
Expand All @@ -63,29 +87,62 @@ private static MutationRequest CreateApprovalRequest()
new PolicyRequirement
{
Type = "Approval",
Description = "Security review",
Description = "Security quorum",
Data = new
{
Approver = "bob",
StepOrder = 1,
Approvers = new[] { "bob", "carol", "dave" },
StepOrder = 2,
ApprovalGroupId = "security-quorum",
Quorum = 2,
Reason = "Security sign-off"
}
},
new PolicyRequirement
{
Type = "Approval",
Description = "Finance review",
Description = "Finance role review",
Data = new
{
Approver = "carol",
StepOrder = 2,
ApproverRole = "finance-approver",
StepOrder = 3,
Reason = "Budget sign-off"
}
}
],
expectedStateVersion: "v10");
}

private static MutationRequest CreateExpiringApprovalRequest()
{
return MutationRequestFactory.PendingApproval(
stateId: "tenant-42:deploy",
stateType: "DeploymentState",
mutationType: "ApproveDeploymentMutation",
intent: new MutationIntent
{
OperationName = "ApproveDeployment",
Category = "Operations",
Description = "Approval request that will expire"
},
context: MutationContext.User("requester", "Requester", "Need emergency deployment approval"),
requirements:
[
new PolicyRequirement
{
Type = "Approval",
Description = "Operations group approval",
Data = new
{
ApproverGroup = "ops-oncall",
StepOrder = 1,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1),
Reason = "Operational readiness"
}
}
],
expectedStateVersion: "v11");
}

private static void PrintSection(string title)
{
Console.WriteLine();
Expand All @@ -102,11 +159,25 @@ private static void PrintRequest(MutationRequest request)
foreach (var requirement in request.ApprovalRequirements.OrderBy(requirement => requirement.StepOrder).ThenBy(requirement => requirement.ApproverId))
{
Console.WriteLine(
$" - Step {requirement.StepOrder}: {requirement.ApproverId} => {requirement.Status}");
$" - Step {requirement.StepOrder}: {DescribeTarget(requirement)} => {requirement.Status} (group: {requirement.ApprovalGroupId ?? "-" }, quorum: {requirement.RequiredApprovals}, expires: {requirement.ExpiresAt?.ToString("O") ?? "-"})");
}

var lastDecision = request.Decisions[^1];
Console.WriteLine($"Last decision: {lastDecision.Type} by {lastDecision.Context.ActorId ?? "system"}");
Console.WriteLine($"Reason: {lastDecision.Reason ?? "-"}");
}

private static string DescribeTarget(MutationApprovalRequirement requirement)
{
if (!string.IsNullOrWhiteSpace(requirement.ApproverId))
return requirement.ApproverId;

if (!string.IsNullOrWhiteSpace(requirement.ApproverRole))
return $"role:{requirement.ApproverRole}";

if (!string.IsNullOrWhiteSpace(requirement.ApproverGroup))
return $"group:{requirement.ApproverGroup}";

return "unknown";
}
}
Loading
Loading