# ApproveApprovalStep

## Permission Scope

request

## Overview

ApproveApprovalStep records an approve decision against a single `ApprovalStepAssignee` row that the calling actor owns and runs the voting evaluation for the parent step. The command validates four guards at the command boundary — the row belongs to `ctx.actorId`, the row is `PENDING`, the parent step is `IN_PROGRESS`, and the parent request is `PENDING` — then writes one immutable `ApprovalDecision` row with `decision = APPROVE`, transitions the assignee row to `APPROVED`, stamps `resolvedAt`, and runs the routing layer's voting tree. The voting evaluation has three checks: every user-direct `required = true` assignee must be APPROVED; every role group must satisfy its `roleQuorum` (`ALL` = every member APPROVED, `ANY` = at least one member APPROVED, null = no group-level constraint); and the total APPROVED count must meet `Step.minimumApprovals`. When all three pass, the step transitions to `APPROVED`, the next step is activated (with role expansion), or — if no further steps remain — the request transitions to `APPROVED`.

## Business Rules

- The calling actor (`ctx.actorId`) must own the target `ApprovalStepAssignee` row (`StepAssignee.userId === ctx.actorId`)
- The target `ApprovalStepAssignee.status` must be `PENDING`
- The parent `ApprovalStep.status` must be `IN_PROGRESS`
- The parent `ApprovalRequest.status` must be `PENDING` — the parent-Request liveness guard rejects step-level commands when the request has terminated by WITHDRAW / CANCEL / APPROVED / REJECTED, even if the assignee row is still PENDING and the step still IN_PROGRESS
- Self-approval is blocked: `ctx.actorId` cannot equal `ApprovalRequest.requesterId` regardless of whether the actor is also listed as an assignee
- Exactly one `ApprovalDecision` row is written with `decision = APPROVE`, `approvalRequestId` and `approvalStepId` both set, `decidedByUserId = ctx.actorId`, and an optional `comment`
- The assignee row transitions to `APPROVED` and `resolvedAt` is set in the same transaction
- Voting evaluation runs after the row update and combines three checks; the step transitions to `APPROVED` only when all three pass:
  - **Per-user required**: every assignee row with `roleId IS NULL` and `required = true` is `APPROVED`
  - **Per-role group quorum**: for every distinct `roleId` on the step, the group's rows satisfy the propagated `roleQuorum` — `ALL` means every member row is `APPROVED`, `ANY` means at least one member row is `APPROVED`, null means no group-level constraint
  - **Step threshold**: the total APPROVED count meets `Step.minimumApprovals`
- Step approval cascades synchronously to the next step's activation (with role expansion) or, if no further steps remain, to `Request.status = APPROVED` and `resolvedAt`
- After role expansion at next-step activation, the activating step's runtime assignee count must be no less than the step's `minimumApprovals`; otherwise activation fails loud

## Process Flow

```mermaid
flowchart TD
    A[Receive approve request] --> B[Load StepAssignee + parent Step + Request<br/>with row lock]
    B --> C{Assignee belongs to ctx.actorId<br/>and StepAssignee.status = PENDING<br/>and Step.status = IN_PROGRESS?}
    C -->|No| CX[Return error: NOT_ACTIVE_ASSIGNEE]
    C -->|Yes| D{Request.status = PENDING?}
    D -->|No| DX[Return error: PARENT_REQUEST_NOT_PENDING]
    D -->|Yes| E{ctx.actorId === Request.requesterId?}
    E -->|Yes| EX[Return error: SELF_DECISION_NOT_ALLOWED]
    E -->|No| F[Insert ApprovalDecision:<br/>decision = APPROVE, both ids set,<br/>decidedByUserId = ctx.actorId,<br/>comment optional]
    F --> G[Update StepAssignee:<br/>status = APPROVED, resolvedAt = now]
    G --> H{Per-user required:<br/>every roleId-null required=true assignee APPROVED?}
    H -->|No| HX[Step stays IN_PROGRESS — return]
    H -->|Yes| H2{Per-role group quorum:<br/>every roleId group satisfies its roleQuorum<br/>ALL = all members / ANY = at least one / null = none?}
    H2 -->|No| HX
    H2 -->|Yes| I{Step threshold:<br/>Total APPROVED count >= Step.minimumApprovals?}
    I -->|No| HX
    I -->|Yes| J[Update Step: status = APPROVED, resolvedAt = now]
    J --> K{Is there a next step<br/>by stepOrder?}
    K -->|Yes| L[Activate next step:<br/>expand role assignees]
    L --> L1{Runtime assignee count<br/>>= nextStep.minimumApprovals?}
    L1 -->|No| L1X[Return error: STEP_QUORUM_UNREACHABLE]
    L1 -->|Yes| L2[Transition next step status to IN_PROGRESS]
    L2 --> M[Return]
    K -->|No| N[Update Request: status = APPROVED,<br/>resolvedAt = now]
    N --> M
```

## External Dependencies

- [user-management::listUsersByRole](../../../user-management/docs/query/ListUsersByRole.md) - Used during next-step activation to materialize role assignees into per-user rows

## Error Scenarios

- **NOT_ACTIVE_ASSIGNEE**: The actor does not own a `PENDING` `ApprovalStepAssignee` row on an `IN_PROGRESS` `ApprovalStep`
- **PARENT_REQUEST_NOT_PENDING**: The parent `ApprovalRequest.status` is not `PENDING`
- **SELF_DECISION_NOT_ALLOWED**: The actor is the requester of the parent request; segregation of duties prohibits self-decision on APPROVE / REJECT / SEND_BACK / DELEGATE
- **ROLE_HAS_NO_ACTIVE_MEMBERS**: A role-based assignee expanded to zero ACTIVE members at step activation
- **STEP_QUORUM_UNREACHABLE**: A step's runtime assignee count after role expansion is less than its `minimumApprovals` threshold

## Test Cases

- approves a single-assignee step and cascades the request to APPROVED when this is the last step
- approves an intermediate step and activates the next step by transitioning it to IN_PROGRESS
- activates an already-expanded next step (resubmit restart) without re-expanding roles
- expands role-based assignees on the next step at activation time
- approves one of N assignees on a step with minimumApprovals = 1 and completes the step
- approves the second of three peers on a step with minimumApprovals = 2 and completes the step
- keeps the step IN_PROGRESS when the APPROVED count reaches minimumApprovals but a required = true assignee is still PENDING
- keeps the step IN_PROGRESS when all required = true assignees are APPROVED but the APPROVED count is below minimumApprovals
- completes the step only when every member of a roleQuorum = ALL group is APPROVED (regardless of step.minimumApprovals)
- completes the step as soon as one member of a roleQuorum = ANY group is APPROVED (subject to step.minimumApprovals)
- ignores roleQuorum when null and falls back to per-user required + step threshold semantics
- writes one ApprovalDecision row per approve command, with both approvalRequestId and approvalStepId set
- accepts an optional comment and stores it on the decision row
- throws NOT_ACTIVE_ASSIGNEE when the actor is not an active assignee on the step
- throws NOT_ACTIVE_ASSIGNEE when the StepAssignee row is already APPROVED
- throws PARENT_REQUEST_NOT_PENDING when the parent request has been WITHDRAWN, CANCELLED, APPROVED, or REJECTED
- throws SELF_DECISION_NOT_ALLOWED when the actor is the requester of the parent request
- throws ROLE_HAS_NO_ACTIVE_MEMBERS when a role-based assignee on the next step expands to zero ACTIVE members at activation time
- throws STEP_QUORUM_UNREACHABLE when next-step role expansion yields fewer runtime assignees than nextStep.minimumApprovals
