# Approval Policy Configuration

## Overview

Approval Policy Configuration lets administrators define reusable approval templates that drive runtime approval requests across any target entity in the system. A policy is composed of three tables — `ApprovalPolicy` (the header that names the template and identifies its purpose), `ApprovalPolicyStep` (the ordered steps that make up the routing chain), and `ApprovalPolicyStepAssignee` (the user or role that must vote on each step). Policies are identified by an opaque `purpose` string (for example `"PRODUCT_ACTIVATION"` or `"PO_CONFIRMATION"`) so that multiple variants can coexist for the same business intent — typically distinguished by name and selected by wrapper module code at request creation time.

When a request is created from a policy, the policy's structure is copied into the request's own runtime tables (`ApprovalRequest`, `ApprovalStep`, `ApprovalStepAssignee`), which then carry the runtime state for that request — per-step status, per-assignee progress, role expansion at activation, and delegation additions. The policy itself is consulted only at creation. `ACTIVE` policies are immutable and `INACTIVE` policies cannot be selected for new requests, so administrators iterate safely: changes can be staged in `DRAFT`, activated atomically, and superseded without disturbing existing approvals.

Policy revisions are tracked by `(purpose, name)` lineage with `activatedAt` ordering. Auditors identify which revision a request was created against by joining `ApprovalRequest.sourcePolicyId` back to `ApprovalPolicy` and reading `name` and `activatedAt` (INACTIVE rows are retained indefinitely).

`INACTIVE` is a terminal state for an approval policy: this module deliberately does not expose a `reactivateApprovalPolicy` command, because `ACTIVE` policies are immutable (so reactivating the row offers no new capability) and the new-DRAFT-then-activate path already covers every "reuse this name" use case while keeping `(purpose, name)` ACTIVE-uniqueness intact.

## Business Purpose

- Provide reusable approval templates per business `purpose` so that wrapper modules do not have to hard-code routing chains in each command
- Allow multiple variants per `purpose` (for example a standard chain plus a high-value chain) so that wrapper modules can pick the right policy in TypeScript using attributes such as amount or category
- Restrict matching to `ACTIVE` policies so that draft work never affects live operations and inactive templates do not reappear in selection lists
- Let in-flight requests run to completion against their own runtime tables, so deactivating a policy never invalidates approvals already under way
- Support both individual user assignees and role-based assignees on each step, so that membership changes in user-management can be picked up at step activation without rebuilding policies
- Express partial-mandatory voting through the combination of `minimumApprovals` per step and the per-assignee `required` flag, so a legal reviewer can be marked mandatory while the remaining managers fulfill a quorum
- Preserve a navigable revision lineage per `(purpose, name)` via retained INACTIVE rows and `activatedAt` ordering, so reviewers can identify which revision a given request was created against

## Process Flow

```mermaid
flowchart TD
    Create[createApprovalPolicy] --> Draft[ApprovalPolicy: DRAFT]
    Draft -->|updateApprovalPolicy| Draft
    Draft -->|activateApprovalPolicy| Active[ApprovalPolicy: ACTIVE]
    Active -->|deactivateApprovalPolicy| Inactive[ApprovalPolicy: INACTIVE]

    Active -.listApprovalPolicies.-> Selection{Wrapper module<br/>selects by purpose<br/>and attributes}
    Selection -.policyId.-> Copy[createApprovalRequest<br/>copies template into<br/>request runtime tables]
    Copy --> Request[ApprovalRequest +<br/>ApprovalStep +<br/>ApprovalStepAssignee]

    Inactive -.in-flight Requests<br/>continue unaffected.-> Request
```

## Scenario Patterns

- **Standard purchase order chain**: An administrator creates a policy for `purpose = "PO_CONFIRMATION"` named "Standard PO" with two steps — direct manager approval, then department director approval — each with a single assignee and `minimumApprovals = 1`
- **High-value variant policy**: A second policy for the same purpose named "High-Value PO" adds a third step requiring CFO sign-off; both policies are activated and the wrapper module picks between them based on the order amount
- **Multi-policy purpose with wrapper selection**: A wrapper module receives a product activation request, looks up the active product-activation policies, and chooses by name based on product category — the approval module itself runs no rule engine
- **Role-based assignee resolved later**: A step assigns the `legal-reviewer` role rather than a specific user; the actual reviewer is resolved at step activation, so a mid-flight role membership change is reflected on steps that have not yet started
- **Required reviewer plus quorum**: A step lists three managers as assignees with `minimumApprovals = 2`, and one of them is marked `required = true`; the step closes only once the required manager has approved and the quorum of two is met
- **Replacement policy does not disturb in-flight requests**: A request is created from a policy; an administrator later activates a new replacement policy with a different chain. The in-flight request finishes against the chain it was created with
- **In-flight requests survive policy deactivation**: A policy is deactivated because a new variant supersedes it; new requests can no longer be created against it, but requests that were already created continue to completion against their own runtime tables
- **Draft revised and re-activated as a new policy**: Because `ACTIVE` policies are immutable, a configuration change is made by creating a new `DRAFT` policy with the corrected steps, activating it, and deactivating the previous policy — leaving an audit trail of both rows
- **Revision lineage by activatedAt**: A `(purpose = "PO_CONFIRMATION", name = "Standard PO")` pair is activated three times across a year as the routing chain evolves; each activation stamps a fresh `activatedAt` while the prior row is deactivated. An auditor reviewing a request created in March can trace it back to the corresponding policy revision because all three rows are retained indefinitely

## Test Cases

- Creating a policy with a `purpose`, a `name`, and at least one step that has at least one assignee succeeds and leaves the policy in `DRAFT`
- Creating a policy with no steps is rejected
- Creating a policy with a step that has no assignees is rejected
- `stepOrder` is unique within a policy; creating two steps with the same `stepOrder` under one policy is rejected
- Each policy assignee sets exactly one of `userId` or `roleId`; assignees that set both or neither are rejected
- A step's `minimumApprovals` defaults to 1 when not supplied and must be a positive integer no greater than the number of assignees on the step
- The `required` flag on an assignee is independent of `minimumApprovals`; a step with `minimumApprovals = 2` and one `required` assignee is accepted as a valid configuration
- `purpose` accepts any non-empty string; the module enforces no enum constraint and stores values such as `"PRODUCT_ACTIVATION"` or `"PO_CONFIRMATION"` as supplied
- `updateApprovalPolicy` succeeds while the policy is in `DRAFT` and may modify the name, description, steps, and assignees
- `updateApprovalPolicy` is rejected when the policy is in `ACTIVE` or `INACTIVE`
- `activateApprovalPolicy` transitions a `DRAFT` policy to `ACTIVE` and stamps `activatedAt`; calling it on an `ACTIVE` or `INACTIVE` policy is rejected
- `activateApprovalPolicy` is rejected if the policy has zero steps or if any step has zero assignees
- `deactivateApprovalPolicy` transitions an `ACTIVE` policy to `INACTIVE` and stamps `deactivatedAt`; calling it on a `DRAFT` or `INACTIVE` policy is rejected
- After deactivation, listing policies filtered by `status = ACTIVE` no longer returns the policy, while in-flight requests created from it continue to progress unchanged
- `ApprovalPolicy.activatedAt` is null while the policy is in `DRAFT` and is set on activation
- `INACTIVE` is a terminal state — there is no `reactivateApprovalPolicy` command; the supported successor path is to create a new `DRAFT` policy under the same `(purpose, name)` and activate it
- INACTIVE policies are retained indefinitely — they are not hard-deleted — so that requests can resolve `sourcePolicyId` back to the policy's `name` and `activatedAt` for reporting and audit purposes
- The `name`, `purpose`, `activatedAt`, and step subtree of an `ACTIVE` or `INACTIVE` policy are immutable
- At most one `ACTIVE` policy can exist for a given `(purpose, name)` pair at any time; activating a new `DRAFT` policy that would collide with an existing `ACTIVE` `(purpose, name)` is rejected (the existing one must be deactivated first, after which a new policy under the same name can be drafted and activated)
- Multiple `DRAFT` policies under the same `(purpose, name)` are allowed (administrators may stage successor variants), but only one of them can be activated; subsequent activations on the same pair are rejected until that policy is deactivated

## Reference Links

- [Approval Request Lifecycle](approval-request-lifecycle.md)
