# Approval Step Routing

## Overview

Approval Step Routing governs the internal mechanics by which an `ApprovalRequest` advances through its ordered list of `ApprovalStep` records. Each step holds one or more `ApprovalStepAssignee` rows that represent the people responsible for deciding that step. This two-layer arrangement (Step ↔ Assignee) is the mechanism that expresses the common business pattern of "one step gated by N approvers" with explicit voting semantics: a `Step.minimumApprovals` threshold combined with a per-assignee `required` flag covers OR (any one of N), AND (all of N), Majority (floor(N/2)+1 of N), and partial-required (one specific approver plus any K others) — without inventing parallel steps for these cases. For role-based assignees the per-row `roleQuorum` enum (`ALL` / `ANY` / null) further gates approval at the group level — `ALL` requires every materialized member of the role to approve, `ANY` requires at least one, and null disables the group-level check so the step's `minimumApprovals` threshold alone governs.

Step activation is the moment role-based assignees resolve to concrete users, the moment voting becomes observable, and the gate that drives the next step. When a `Request` is created the lowest `stepOrder` step is activated inline (`status = IN_PROGRESS`); trailing steps stay inert in `PENDING`. Each time a step completes APPROVED, the next step transitions in turn. **Any** REJECT — whether from a `required` or an optional assignee — completes the step REJECTED immediately and cascades the parent Request to REJECTED; trailing steps remain in PENDING (there is no SKIPPED state). The `required` flag affects only the approve-side completion criteria (`required` assignees must all have APPROVED before the step can complete APPROVED); it does not soften the reject path.

Send-back introduces a **restart** path: when the requester resubmits a sent-back request, approval starts over from the first step — runtime progress is reset while every prior decision stays in the `ApprovalDecision` log. Two behaviors are deliberate. The restart **reuses the frozen assignee set**: roles are not re-expanded, so membership changes since the first round do not reach already-activated steps (a never-reached step still expands when reached). And delegation carries across rounds — the delegatee keeps the seat, not the original delegator.

## Business Purpose

- Support multi-approver gating on a single business step without forcing those approvers to be modeled as separate sequential steps
- Express OR voting through `minimumApprovals = 1` with no `required` assignees, so the first APPROVE finishes the step
- Express AND voting through `minimumApprovals = N` (or by marking every assignee `required = true`), requiring every named approver to approve
- Express Majority voting through `minimumApprovals = floor(N/2)+1`, allowing the step to finish as soon as a quorum is reached
- Express partial-required voting through one or more `required = true` assignees plus a `minimumApprovals` threshold, so a specific approver must always approve while the remaining slots are filled by any of the optional approvers
- Express group-level AND voting on a role-based assignee through `roleQuorum = 'ALL'`, requiring every materialized member of the role to approve at runtime
- Express group-level OR voting on a role-based assignee through `roleQuorum = 'ANY'`, allowing any one member of the role to satisfy the group regardless of how many other members are still PENDING
- Disable the group-level check by leaving `roleQuorum = null`, so that role-expanded members vote only as individual contributors to the step's `minimumApprovals` threshold without any group-level constraint
- Reflect role membership at the moment work actually begins by expanding role-based assignees into per-user rows when the step transitions to IN_PROGRESS, picking up org changes that happened after the request was created
- Freeze the assignee set once a step is active so that later membership changes do not silently retarget in-flight approvals
- Advance sequentially: a later step is invisible to its assignees until the prior step completes APPROVED
- Fast-fail on rejection: a single REJECTED step rejects the entire Request without waiting for trailing steps
- Restart the chain from the first step on resubmission, reusing the frozen assignee set without re-expanding roles or re-reading the policy
- Support delegation as a per-row state transition that adds a new participant rather than mutating history
- Retain immutable per-assignee progress (`status`, `resolvedAt`) so the audit trail reflects what each individual decided

## Process Flow

The first diagram is the per-step lifecycle. Role-based assignees are expanded into per-user assignees at activation, picking up the current role membership.

```mermaid
flowchart TD
    A[Step created on Request creation] --> B["Step: PENDING"]
    B --> C{Prior step APPROVED<br/>or this is the first step?}
    C -->|Not yet| B
    C -->|Yes| D[Activate step:<br/>expand role-based assignees<br/>into per-user assignees]
    D --> E["Step: IN_PROGRESS"]
    E --> F{Decisions on assignees}
    F -->|Voting satisfied| G["Step: APPROVED"]
    F -->|Any REJECT| H["Step: REJECTED"]
    G --> I[Activate next step]
    H --> J[Cascade Request to REJECTED;<br/>trailing steps stay PENDING]
```

The second diagram is the voting evaluation that runs after each assignee decision.

```mermaid
flowchart TD
    A[Decision recorded on an assignee] --> B{Decision type}
    B -->|APPROVE| C{All required assignees<br/>have APPROVED?}
    C -->|No| E[Step stays IN_PROGRESS]
    C -->|Yes| D2{Every role group<br/>satisfies its roleQuorum?<br/>ALL = every member APPROVED<br/>ANY = at least one APPROVED<br/>null = no group check}
    D2 -->|No| E
    D2 -->|Yes| F{Total APPROVED count<br/>≥ minimumApprovals?}
    F -->|No| E
    F -->|Yes| G["Step: APPROVED"]
    G --> H[Activate next step]
    B -->|REJECT| I["Step: REJECTED"]
    I --> J["Request: REJECTED"]
    J --> K[Trailing steps remain PENDING]
```

## Scenario Patterns

- **Single-approver step**: The step has one assignee and `minimumApprovals = 1`. The first APPROVE completes the step and activates the next one
- **Manager-OR (any of three)**: Three managers are assigned to the same step with `required = false` and `minimumApprovals = 1`. Whichever manager approves first completes the step
- **Two-of-three majority**: Three peers are assigned with `required = false` and `minimumApprovals = 2`. The step completes as soon as the second APPROVE arrives, regardless of which two
- **AND across all assignees**: Every assignee is marked `required = true` (or `minimumApprovals` equals the assignee count). Every named approver must approve before the step completes
- **Legal-required plus flexible 1-of-N**: A single Legal user is `required = true` and five managers are `required = false` with `minimumApprovals = 2`. Legal must always approve, plus any one of the managers
- **Role group AND vote**: A step assigns the FINANCE_MANAGERS role with `roleQuorum = 'ALL'`; the step completes only after every current finance manager approves
- **Role group OR vote**: A step assigns the DIRECTORS role with `roleQuorum = 'ANY'` and `minimumApprovals = 1`; the first APPROVE from any current director completes the step
- **Role group as individual contributors**: A step assigns the MANAGERS role with `roleQuorum = null` and `minimumApprovals = 2`; the second APPROVE from any current manager completes the step — there is no group-level constraint and the threshold is the only gate
- **Role-expanded assignee set freezes at activation**: A step assigned to the FINANCE_MANAGERS role expands to the four current managers when it activates; the set is then frozen for the duration of that step
- **Member added to role after the step activated**: A fifth finance manager joins the role after activation. They do not appear on this step, because the assignee set was frozen at the activation moment
- **Member added to role before a later step activates**: A finance manager joins the role while step 1 is still in progress. When step 2 activates later, the new member is included in the expansion of step 2
- **Sequential three-step chain**: A request with three steps activates step 1 on submit, step 2 on step 1's APPROVED, step 3 on step 2's APPROVED — each transition gated by the prior step's voting result
- **Step REJECTED rejects the whole Request**: A reject on any step terminates the Request immediately; trailing steps stay PENDING and the Request status communicates that the request was not executed
- **Optional assignee REJECT also fails the step**: A step has `minimumApprovals = 2`, three optional assignees (all `required = false`), and Manager A APPROVES while Manager B REJECTS. Even though Manager C is still PENDING and the threshold could mathematically still be met, the REJECT immediately fails the step. This is intentional — `required` only governs the approve path
- **Delegation routes one assignee's responsibility**: An assignee delegates their seat to another user; the original assignee is marked DELEGATED and the delegatee participates in the same vote
- **Send-back then resubmit restarts the chain**: A three-step request has step 1 APPROVED and step 2 IN_PROGRESS when an approver sends it back. After resubmission, approval restarts from step 1; step 3 was never reached, so its role seed expands only when it is reached

## Test Cases

- `stepOrder` is UNIQUE within an `ApprovalRequest`; inserting two steps with the same order in the same request fails
- On request creation, only the step with the lowest `stepOrder` is set to IN_PROGRESS; all other steps remain PENDING
- A later step transitions to IN_PROGRESS only after the immediately prior step reaches APPROVED
- A step does not advance to APPROVED until both the `required = true` assignee set is fully APPROVED and the APPROVED count meets `Step.minimumApprovals`
- A step whose APPROVED count meets `minimumApprovals` but still has at least one PENDING `required = true` assignee remains IN_PROGRESS
- A step whose `required = true` assignees are all APPROVED but whose total APPROVED count is below `minimumApprovals` remains IN_PROGRESS
- Role expansion runs at the PENDING → IN_PROGRESS transition, not at request creation
- Role expansion produces one assignee per current role member, with `userId` set and the originating `roleId` carried for traceability
- Role membership change after a step is activated does not alter the step's frozen assignee set
- Role membership change before a still-PENDING step activates is reflected when that step later transitions to IN_PROGRESS
- A role with zero current members at the activation moment fails loud rather than silently auto-completing the step
- Any REJECT — whether from a `required = true` or `required = false` assignee — fails the step immediately and cascades the parent Request to REJECTED; the `required` flag has no effect on the reject path
- All `required = true` assignees must individually reach APPROVED for the step to complete APPROVED, regardless of how many optional approvals occurred (the `required` flag governs only the approve-side completion criteria)
- A step does not advance to APPROVED while any role group has an unsatisfied `roleQuorum`: `ALL` requires every materialized member of that group to be APPROVED, `ANY` requires at least one member of the group to be APPROVED, and null skips the group check entirely
- A step with a `roleQuorum = 'ALL'` group completes APPROVED only when every materialized member of that group is APPROVED, regardless of `step.minimumApprovals` (the threshold becomes redundant once the group is fully approved)
- A step with a `roleQuorum = 'ANY'` group completes APPROVED as soon as one member of that group is APPROVED, subject to `step.minimumApprovals` and the per-user `required` set on the same step
- A step with `roleQuorum = null` on a role assignee falls back to per-user required + step threshold semantics for the group's materialized members; there is no group-level constraint
- When the `required = true` set is large enough that `minimumApprovals <= count(required)`, the threshold is automatically satisfied as a side-effect of all `required` assignees approving — the configuration is valid but `minimumApprovals` becomes redundant
- A reject on any step cascades to `Request.status = REJECTED` immediately
- Trailing steps remain in PENDING after a rejection; no SKIPPED state is introduced
- A delegated assignee is marked DELEGATED, and a new assignee is added for the delegatee
- The delegatee's assignee participates in the step's voting on equal footing with originally-assigned assignees
- On resubmission, every `ApprovalStep` is reset to `PENDING` with `resolvedAt` cleared, the lowest-`stepOrder` step is re-activated to `IN_PROGRESS`, and every assignee that had reached `APPROVED` is reset to `PENDING` with `resolvedAt` cleared (a sent-back request never has a `REJECTED` assignee, since reject is terminal)
- On resubmission, `DELEGATED` assignees are not reset; the delegatee's row remains the seat's only active voter
- Restart reuses the existing expanded assignee rows and does not re-expand roles or re-read the source policy; an already-expanded step keeps its per-user rows, and a not-yet-reached step still expands its role seed when it is reached
- Restart does not delete or mutate any `ApprovalDecision` row

## Reference Links

- [Approval Decision Recording](approval-decision-recording.md)
- [Approval Request Lifecycle](approval-request-lifecycle.md)
