# Approval Request Lifecycle

## Overview

Approval Request Lifecycle governs how an `ApprovalRequest` — the runtime instance of an approval workflow — is created, resolved, and terminated against any target entity in the system. A request is the history-anchored record that ties a requester, a polymorphic `(targetEntityType, targetEntityId)` pair, an action-shaped `purpose`, and an ordered chain of runtime steps together under a single status state machine.

A request can be created in two modes. In **policy mode**, the caller passes a `policyId` and the module copies the policy's structure (steps and assignees) into the request's runtime tables (`ApprovalRequest` / `ApprovalStep` / `ApprovalStepAssignee`); `sourcePolicyId` records the originating policy for lineage. In **direct mode**, the caller passes `steps: [...]` inline and `sourcePolicyId` is null. In both cases the request runs against its own runtime tables and never re-reads the source policy thereafter (see [Approval Policy Configuration](approval-policy-configuration.md) for revision lineage). This feature focuses on the request-level state machine and its create / withdraw / cancel / resubmit commands. The mechanics inside steps (assignee voting, role-to-user expansion, step advancement, and the restart that resubmission triggers) live in [Approval Step Routing](approval-step-routing.md), and the per-decision command surface and immutable `ApprovalDecision` log — including the WITHDRAW / CANCEL / RESUBMIT rows that the request-level commands here emit — live in [Approval Decision Recording](approval-decision-recording.md).

Beyond the terminal outcomes, a request supports a **non-terminal send-back** with two destinations, selected by the optional `targetApprovalStepId` on `sendBackApprovalStep`. In **requester mode** (no target) the request moves from `PENDING` to `REVISION_REQUESTED`, handing it back to the requester; the requester revises the target and calls `resubmitApprovalRequest`, which returns it to `PENDING` and restarts approval from the first step. In **step mode** (a target supplied) the request stays `PENDING` and rewinds to a chosen earlier step so a prior approver can reconsider without the requester's involvement — the target-through-current steps reset and the target step re-activates. Unlike `rejectApprovalStep` both are non-terminal — the same request and its decision log carry through.

## Business Purpose

- Capture history-anchored approval requests as standalone documents whose lifecycle is independent of the target entity's own status
- Bind polymorphically to any target via `(targetEntityType, targetEntityId)` so a single approval module serves Product, PurchaseOrder, or any wrapper-defined entity without per-type table generation
- Support two creation modes — templated expansion from an `ApprovalPolicy` and ad-hoc inline `steps` — so both governed flows and one-off requests share the same runtime
- Run each in-flight request against its own runtime step and assignee rows, so the request executes the chain it was created with and is unaffected by later policy lifecycle changes (deactivation or replacement)
- Keep request state and target state separate; the approval module never writes to the target, leaving the wrapper command responsible for syncing the target after resolution
- Treat the send-back round-trip's target side as the wrapper's responsibility: whether and how the target becomes editable during `REVISION_REQUESTED` is wrapper logic, and the approval module performs no change detection on resubmit
- Allow the requester to self-service withdraw their own pending request via `withdrawApprovalRequest` without administrator involvement, recording the action as a `WITHDRAW` row in `ApprovalDecision`
- Provide a `cancelApprovalRequest` override so stuck or abandoned requests can be terminated, recording the action and reason as a `CANCEL` row in `ApprovalDecision`
- Support send-back for revision as a non-terminal alternative to rejection, so a correctable request is revised and resubmitted instead of being terminated and recreated
- Support step-mode send-back so an approver can rewind the request to any earlier step (chosen via `targetApprovalStepId`) for a prior approver to reconsider, keeping the request `PENDING` and never routing through the requester
- Surface the requester's own requests — especially those in `REVISION_REQUESTED` awaiting resubmission — through `listApprovalRequestsForRequester`
- Enforce per-target exclusivity through `getActiveApprovalRequest({ targetEntityType, targetEntityId })`, which wrapper commands call to block concurrent approval requests on the same entity
- Preserve full request history per target via `listApprovalRequestsByTarget` so the rework substitute (cancel/reject + new request) keeps the trail intact instead of mutating the original
- Store `purpose` as an opaque action-verb-shaped string (e.g., `PRODUCT_ACTIVATION`, `PO_CONFIRMATION`) chosen by the wrapper module

## Process Flow

```mermaid
flowchart TD
    P1[policyId mode\ncopy template from ApprovalPolicy] --> A
    P2[direct steps mode\ninline step definitions] --> A
    A[createApprovalRequest] --> D["status: PENDING\nfirst step activated"]
    D --> W["status: WITHDRAWN\nApprovalDecision: WITHDRAW"]
    D --> X["status: CANCELLED\nApprovalDecision: CANCEL"]
    D --> E{Step decisions\n(see approval-decision-recording.md)}
    E -->|All steps approved| F["status: APPROVED"]
    E -->|Any step rejected| G["status: REJECTED\nrejectionReason populated"]
    E -->|Approver sends back to requester| R["status: REVISION_REQUESTED\nApprovalDecision: SEND_BACK"]
    E -->|"Approver sends back to an earlier step\n(stays PENDING; steps target..current reset,\ntarget step re-activated)\nApprovalDecision: SEND_BACK + sentBackToStepId"| D
    R -->|resubmitApprovalRequest| D2["status: PENDING\nsteps reset, first step re-activated"]
    R --> W
    R --> X
    D2 --> E
    F --> Z[(terminal)]
    G --> Z
    W --> Z
    X --> Z
```

## Scenario Patterns

- **Wrapper-orchestrated standard request**: A product-activation wrapper command picks an active policy by purpose and creates an approval request against the target Product
- **Ad-hoc one-off approval**: An operator triggers a wrapper command that supplies an inline step list (no permanent policy exists for this purpose); the request runs against those inline steps with no source policy attached
- **Requester withdraws after creation**: The requester realizes the target data was wrong and withdraws while the request is still PENDING; the request transitions to WITHDRAWN and no further step decisions are accepted
- **Cancelling a stuck request**: An assignee has been unavailable for days; an operator cancels with a reason and the request transitions to CANCELLED, unblocking the target
- **Send back for revision, then resubmit**: An approver finds the target data needs correction but does not want to kill the request; they send it back with a reason, moving it to REVISION_REQUESTED. The requester fixes the target and resubmits, returning the request to PENDING with approval restarted from the first step
- **Send back to an earlier approval step**: A step-3 approver decides step 1's approval was premature; they send the request back to step 1 with a reason and `targetApprovalStepId` set. The request stays PENDING, steps 1–3 reset, step 1 re-activates, and approval flows forward again from step 1 — the requester is never involved
- **Reject as a hard stop**: When a request should not proceed at all (not merely be corrected), an approver rejects it; the request terminates as REJECTED. The requester's recourse is a brand-new request, and the prior REJECTED request remains discoverable through the per-target history
- **Withdraw or cancel while awaiting revision**: A request sent back for revision is later abandoned by the requester (withdraw) or terminated by an administrator (cancel) instead of being resubmitted
- **Multiple sequential requests on the same target**: Over the lifetime of a Product, several activation, deactivation, and price-change requests are issued; each is its own request and the history accumulates against the same target
- **Wrapper command blocks duplicate concurrent requests**: Before creating a new request, the wrapper checks for an active one on the same target; if one exists, the wrapper short-circuits with an "already in progress" error rather than creating a duplicate
- **Final approval triggers wrapper to act on target**: When the last step's decision flips the request to APPROVED, the wrapper command acts on the target — for example, calling the base module's product-activation command
- **In-flight request unaffected by policy lifecycle**: A policy is deactivated or replaced after a request was created from it; the request's own runtime steps and assignees execute unchanged

## Test Cases

- Creating an approval request in `policyId` mode should succeed and persist `status` = `PENDING`, `requesterId` = the actor, and `sourcePolicyId` = the supplied policy id
- Creating an approval request in `policyId` mode should copy each policy step and assignee into the request side with the same `stepOrder`, `name`, `minimumApprovals`, `userId`, `roleId`, and `required` values
- Creating an approval request in direct mode should persist the provided step and assignee definitions verbatim and leave `sourcePolicyId` null
- `name` is required on `createApprovalRequest`; a request created without a display label should be rejected
- `targetEntityType` and `targetEntityId` are required; the module does not validate them against any target table, so a non-existent or typo'd target id is accepted at the request level
- `purpose` is stored as an opaque string and is not validated against an enum
- `createApprovalRequest` should fail when the supplied `steps` array is empty (direct mode)
- `createApprovalRequest` should activate the lowest-`stepOrder` step (`status = IN_PROGRESS`), expand role-based assignees into per-user assignees, and persist `status = PENDING` on the request
- Only the requester is allowed to call `withdrawApprovalRequest`; any other user should be rejected
- `withdrawApprovalRequest` should be allowed when `status` is `PENDING` or `REVISION_REQUESTED` and should transition the request to `WITHDRAWN` with `resolvedAt` set, recording an `ApprovalDecision` with `decision = WITHDRAW` and `approvalStepId` null
- `withdrawApprovalRequest` should fail once the request is in a terminal state (`APPROVED`, `REJECTED`, `CANCELLED`, `WITHDRAWN`)
- `cancelApprovalRequest` should require a non-empty `reason`; calls without it should be rejected
- `cancelApprovalRequest` should be allowed when `status` is `PENDING` or `REVISION_REQUESTED` and should transition the request to `CANCELLED` with `resolvedAt` set, recording an `ApprovalDecision` with `decision = CANCEL`, `approvalStepId` null, and `comment = reason`
- `withdrawApprovalRequest` and `cancelApprovalRequest` do not modify `ApprovalStep.status` or `ApprovalStepAssignee.status`; trailing or in-progress step rows remain in their pre-termination status and the parent `ApprovalRequest.status` is the single signal that the work is closed
- Terminal states are immutable: attempting to withdraw or cancel an `APPROVED` / `REJECTED` / `CANCELLED` / `WITHDRAWN` request should fail
- When the final step's decision results in step approval, the request `status` should transition to `APPROVED` and `resolvedAt` should be set
- When any step is rejected, the request `status` should transition to `REJECTED`, `resolvedAt` should be set, and `rejectionReason` should be populated from the rejecting decision
- When an approver sends a step back in requester mode (no `targetApprovalStepId`), the request `status` should transition to `REVISION_REQUESTED` (non-terminal, `resolvedAt` left null) and a `SEND_BACK` decision should be recorded with `sentBackToStepId` null; `ApprovalStep` and `ApprovalStepAssignee` rows are left unchanged until resubmission
- When an approver sends a step back in step mode (a `targetApprovalStepId` for an earlier step), the request `status` should stay `PENDING`, the target-through-current steps should reset to `PENDING`, the target step should re-activate to `IN_PROGRESS`, `APPROVED` assignees on those steps should reset to `PENDING` (`DELEGATED` left as-is), and a `SEND_BACK` decision should be recorded with `sentBackToStepId` = the target step
- Step-mode send-back should fail with `INVALID_SEND_BACK_TARGET` when the target step is not in the same request or is not strictly earlier than the actor's current step
- `resubmitApprovalRequest` should be allowed only when `status` is `REVISION_REQUESTED` and only for the requester; it should return the request to `PENDING`, reset every `ApprovalStep` back to `PENDING` and re-activate the lowest-`stepOrder` step (`IN_PROGRESS`), and reset previously-approved `ApprovalStepAssignee` rows to `PENDING` (`DELEGATED` rows are left as-is)
- `resubmitApprovalRequest` should fail when the request is `PENDING` or in any terminal state, and when called by a non-requester
- `getActiveApprovalRequest` should return the open request for the given target when one exists in `PENDING` **or** `REVISION_REQUESTED` status, and null otherwise
- `listApprovalRequestsForApprover` (the My Inbox query) returns requests where the user is currently a PENDING assignee on an IN_PROGRESS step **and** the parent `ApprovalRequest.status` is `PENDING`; requests that have terminated (APPROVED / REJECTED / CANCELLED / WITHDRAWN) are excluded even when the underlying assignee row is still PENDING
- `listApprovalRequestsForApprover` accepts an optional `status` filter to narrow by request status (defaults to `PENDING` when omitted) and is the canonical source for an approver's active workload
- `listApprovalRequestsForRequester` returns requests where the user is the `requesterId`, with an optional `status` filter (defaults to `REVISION_REQUESTED`)

## Reference Links

- Models: `ApprovalRequest`, `ApprovalStep`, `ApprovalStepAssignee`, `ApprovalDecision` (see `../model/`)
