# Approval Decision Recording

## Overview

Approval Decision Recording covers every actor-driven transition on an `ApprovalRequest` — `approveApprovalStep`, `rejectApprovalStep`, `sendBackApprovalStep`, `delegateApprovalStep`, `withdrawApprovalRequest`, `cancelApprovalRequest`, and `resubmitApprovalRequest` — and the immutable `ApprovalDecision` rows they produce. Step-level decisions (APPROVE / REJECT / SEND_BACK / DELEGATE) target a specific `ApprovalStepAssignee` that the actor owns and validate two preconditions at the command boundary: (1) **segregation of duties** — the requester of the parent `ApprovalRequest` cannot record any of APPROVE / REJECT / SEND_BACK / DELEGATE on their own request; and (2) **parent-state liveness** — `ApprovalRequest.status` must still be `PENDING`, so a step-level command on a request that has already terminated (or is awaiting revision) is rejected even if the StepAssignee row is still PENDING and the Step still IN_PROGRESS. Request-level decisions (WITHDRAW / CANCEL / RESUBMIT) target the request as a whole and have no step or assignee context. In every case exactly one `ApprovalDecision` row is persisted per command, capturing who decided what, when, with an optional `comment` and — for delegate — a `delegatedToUserId`. The row is append-only: once written it cannot be updated or deleted.

`ApprovalDecision` is the single canonical history for a request. Every transition that changes the request's effective status appears as a row; a single `getApprovalDecisionHistory({ approvalRequestId })` query returns the full chronological timeline. The `comment` on a REJECT decision and the `rejectionReason` on the parent `ApprovalRequest` are written as the same string: per-decision detail lives on `ApprovalDecision.comment`, and the request-level summary used by list views lives on `ApprovalRequest.rejectionReason`.

## Business Purpose

- Capture an immutable per-decision history covering both step-level and request-level transitions, so every APPROVE / REJECT / SEND_BACK / DELEGATE / WITHDRAW / CANCEL / RESUBMIT leaves a permanent, attributable trail in one place
- Enforce segregation of duties at the command boundary on **all step-level outcomes**: the requester of a request cannot APPROVE, REJECT, SEND_BACK, or DELEGATE on their own request — even if they are listed as an active assignee
- Enforce a parent-Request liveness check on every step-level command — `Request.status` must be `PENDING` — so that a step-level decision arriving after the parent has been WITHDRAWN, CANCELLED, APPROVED, REJECTED, or moved to REVISION_REQUESTED is rejected
- Require a structured `reason` on REJECT, SEND_BACK, and CANCEL so the termination or send-back trail always carries an explanation
- Support delegation as a traceable substitution: the original assignee is marked DELEGATED, a new `ApprovalStepAssignee` row is inserted for the delegatee, and the `ApprovalDecision` row records the linkage via `delegatedToUserId`
- Dual-write the rejection summary to `ApprovalRequest.rejectionReason` so list-view queries can display a representative reason without joining `ApprovalDecision`
- Provide a single chronological query (`getApprovalDecisionHistory`) that returns every actor-driven transition for a request, removing the need for consumers to join a separate audit log
- Block self-delegation (delegating to oneself) and circular delegation (delegating to a user already active as an assignee on the same step) so the assignee set always grows toward resolution
- Restrict `withdrawApprovalRequest` and `resubmitApprovalRequest` to the requester themselves, recording the actor on the resulting decision row so the audit trail captures who performed the termination or resubmission
- Allow `comment` to be optional on APPROVE, DELEGATE, and RESUBMIT so actors can record context (e.g., "stock confirmed", "out of office — handing to Carol") without making it mandatory
- Defer step advancement and request state cascade to the routing layer ([Approval Step Routing](approval-step-routing.md)) so this feature stays focused on persisting the decision and updating the assignee or request row

## Process Flow

```mermaid
flowchart TD
    A[Actor invokes a decision command] --> B{Step-level or<br/>request-level?}

    B -->|step-level| C{Guards:<br/>actor owns an active assignee<br/>+ Request still PENDING<br/>+ actor is not the requester}
    C -->|fail| CX[Reject — no decision row written]
    C -->|pass| D{Outcome}
    D -->|APPROVE| DA[Write ApprovalDecision: APPROVE<br/>mark assignee APPROVED]
    DA --> DA1[Defer to step routing for<br/>voting evaluation and advancement]
    D -->|REJECT| DR[Write ApprovalDecision: REJECT<br/>cascade Step and Request to REJECTED<br/>copy reason to Request.rejectionReason]
    D -->|SEND_BACK<br/>reason required| DS[Write ApprovalDecision: SEND_BACK<br/>Request to REVISION_REQUESTED<br/>Step and assignee rows untouched]
    D -->|DELEGATE| DD[Write ApprovalDecision: DELEGATE<br/>mark original assignee DELEGATED<br/>add new assignee for the delegatee]

    B -->|request-level| E{Action}
    E -->|WITHDRAW / CANCEL| E1{Request PENDING<br/>or REVISION_REQUESTED?}
    E -->|RESUBMIT| E2{Request REVISION_REQUESTED<br/>+ actor is the requester?}
    E1 -->|No| EX[Reject — terminal state]
    E2 -->|No| EX
    E1 -->|Yes| F{Outcome}
    F -->|WITHDRAW<br/>requester only| FW[Write ApprovalDecision: WITHDRAW<br/>Request to WITHDRAWN]
    F -->|CANCEL<br/>reason required| FC[Write ApprovalDecision: CANCEL<br/>Request to CANCELLED]
    E2 -->|Yes| FR[Write ApprovalDecision: RESUBMIT<br/>Request to PENDING<br/>defer step restart to step routing]
```

## Scenario Patterns

- **Approver approves with optional comment**: An assignee approves their step with a confirmation comment ("Stock and budget confirmed"); the decision is recorded and step routing decides whether voting is satisfied
- **Approver rejects with required reason**: An assignee rejects with a structured reason ("Exceeds Q2 budget cap"); the step and the request both terminate as REJECTED and the reason is preserved on the request for list-view reporting
- **Approver sends back for revision with required reason**: An assignee sends the request back with a structured reason ("Attach the signed vendor quote"); the request moves to REVISION_REQUESTED and nothing terminates
- **Requester resubmits after revising the target**: The requester fixes the target and calls resubmit; a RESUBMIT decision is recorded and the request returns to PENDING
- **Manager delegates to a peer for vacation coverage**: A CFO assignee delegates their seat to a peer with a context note ("Out of office until Monday — Carol owns this"); voting continues with the peer participating in place of the original assignee
- **Requester attempts a step-level decision on their own request**: The requester is also listed as an assignee; any APPROVE / REJECT / DELEGATE they attempt is rejected before any history is recorded. The block applies symmetrically to all three outcomes
- **Step-level decision arrives after the request was withdrawn**: The requester withdraws while assignees still have open work. A late approve from one of those assignees is rejected because the parent request has already terminated; nothing is recorded
- **Approver attempts circular delegation**: An assignee tries to delegate to someone already active on the same step; the attempt is rejected and nothing is recorded, ensuring the assignee set converges toward resolution
- **Requester withdraws after submission**: The requester realizes the target data was wrong and withdraws while the request is still PENDING; the decision is recorded and the request transitions to WITHDRAWN
- **Cancelling a stuck request**: An assignee has been unavailable for days; an operator cancels the request with a reason ("Requester left company"); the reason is preserved on the decision and the request transitions to CANCELLED
- **Multiple approvers on a step approve in sequence**: Three assignees on the same step each approve in turn; each decision is recorded individually and the step resolves once the configured `required` set and `minimumApprovals` threshold are satisfied
- **Decision history surfaces the full request timeline**: A consumer queries the decision history for a request and receives every actor-driven transition — APPROVE, REJECT, SEND_BACK, DELEGATE, WITHDRAW, CANCEL, RESUBMIT — chronologically ordered; this is the single source of truth for who did what, when
- **Reject is final; send-back is not**: After a REJECT, the request is terminal and the only recourse is a brand-new request; SEND_BACK is the non-terminal alternative

## Test Cases

- `ApprovalDecision` rows are append-only; updating or deleting them is rejected
- `ApprovalDecision.decidedByUserId` records the actor performing the command and cannot be supplied by the caller
- `ApprovalDecision.approvalRequestId` is required on every row
- `ApprovalDecision.approvalStepId` is set on `APPROVE`, `REJECT`, `SEND_BACK`, and `DELEGATE` rows and is null on `WITHDRAW`, `CANCEL`, and `RESUBMIT` rows
- `approveApprovalStep` marks the target assignee as `APPROVED` with `resolvedAt` set
- `rejectApprovalStep` requires a non-empty `reason`; calls with an empty or missing reason are rejected
- `rejectApprovalStep` marks the target assignee as `REJECTED` with `resolvedAt` set, and the same string is recorded on both `ApprovalDecision.comment` and `ApprovalRequest.rejectionReason`
- `sendBackApprovalStep` requires a non-empty `reason`; calls with an empty or missing reason are rejected
- `sendBackApprovalStep` records a `SEND_BACK` decision with `comment = reason` and `approvalStepId` set, transitions the request to `REVISION_REQUESTED`, and leaves `ApprovalStep` and `ApprovalStepAssignee` rows unchanged
- `delegateApprovalStep` adds a new assignee for the delegatee with `required` carried over from the original assignee and `status = PENDING`
- `delegateApprovalStep` marks the original assignee as `DELEGATED` with `resolvedAt` set
- `delegateApprovalStep` rejects when the delegatee is the actor themselves (self-delegation)
- `delegateApprovalStep` rejects when the delegatee is already an assignee on the same step (circular delegation)
- `approveApprovalStep`, `rejectApprovalStep`, `sendBackApprovalStep`, and `delegateApprovalStep` all reject when the actor is the request's requester (self-decision-not-allowed); the rule applies to all step-level outcomes
- All step-level commands reject when `ApprovalRequest.status` is not `PENDING` (the parent is already WITHDRAWN, CANCELLED, APPROVED, REJECTED, or REVISION_REQUESTED), even if the StepAssignee row is still PENDING and the Step is still IN_PROGRESS — the request-level status is authoritative
- `withdrawApprovalRequest` is allowed only when the actor is the requester; calls from any other user are rejected
- `withdrawApprovalRequest` is allowed in `PENDING` or `REVISION_REQUESTED` and records an `ApprovalDecision` with `decision = WITHDRAW` and `approvalStepId` null
- `withdrawApprovalRequest` transitions the request to `WITHDRAWN` and sets `resolvedAt`
- `cancelApprovalRequest` requires a non-empty `reason`; calls without it are rejected
- `cancelApprovalRequest` is allowed in `PENDING` or `REVISION_REQUESTED` and records an `ApprovalDecision` with `decision = CANCEL`, `approvalStepId` null, and `comment = reason`
- `cancelApprovalRequest` transitions the request to `CANCELLED` and sets `resolvedAt`
- `resubmitApprovalRequest` is allowed only when the actor is the requester and the request is `REVISION_REQUESTED`; calls from any other user, or in `PENDING` or any terminal state, are rejected
- `resubmitApprovalRequest` records an `ApprovalDecision` with `decision = RESUBMIT`, `approvalStepId` null, and an optional `comment`, and transitions the request to `PENDING` (the step/assignee restart is asserted in [Approval Step Routing](approval-step-routing.md))
- `ApprovalDecision.comment` is optional on `APPROVE`, `DELEGATE`, `WITHDRAW`, and `RESUBMIT`
- `ApprovalDecision.delegatedToUserId` is null on `APPROVE`, `REJECT`, `SEND_BACK`, `WITHDRAW`, `CANCEL`, and `RESUBMIT` decisions and non-null on `DELEGATE` decisions
- An actor who is not currently an active (`PENDING`) assignee on the step cannot record any step-level decision against it
- `getApprovalDecisionHistory` returns every decision recorded for the request — APPROVE, REJECT, SEND_BACK, DELEGATE, WITHDRAW, CANCEL, RESUBMIT — in chronological order
- Step advancement, voting evaluation, and the `Step` / `Request` status cascade after a step-level decision is recorded are deferred to the logic documented in [Approval Step Routing](approval-step-routing.md)

## Reference Links

- [Approval Step Routing](approval-step-routing.md) — voting and step advancement after a step-level decision is recorded
- [Approval Request Lifecycle](approval-request-lifecycle.md) — request-level state transitions driven by withdraw, cancel, and resubmit
