Use for any transition of approval, signing, or signer status (approve/reject/sign/decline/expire/revoke/sequential-sign round). Status may change only via transition tables; defines transaction boundaries, independence of the two machines, and where company-seal-requires-approval is enforced.
Install
npx skillscat add sundaydev-arch/hrsign/state-machines Install via the SkillsCat registry.
SKILL.md
Skill 02: Dual state machines (approval and signing are separate)
When to use
- Change approve/reject/sign/expire/revoke logic
- Add a task operation (e.g. withdraw, transfer sign)
- Decide whether a given signer can act now
State space (authoritative definitions in schema.prisma; transition rules in state-machines/)
- ApprovalStatus: DRAFT → PENDING → APPROVED | REJECTED | WITHDRAWN
- SigningStatus: NOT_STARTED → IN_PROGRESS → COMPLETED | DECLINED | EXPIRED | REVOKED
- Signer: PENDING → VIEWED → SIGNED | DECLINED
- SignerRole: APPROVER (approver) / COMPANY_SEAL (company seal) / PERSONAL_SIGNATURE (personal sign). Deprecated enums COMPANY_SIGNER/REJECTED/WAITING must not reappear.
Hard rules
- Legal transitions live only in the transition tables (approval.ts / signing.ts / signer.ts). Route handlers must not write bare jumps like
if (status===...) status='...'; illegal transitions throw from the state machine. - Company seal requires prior approval: COMPANY_SEAL is allowed only when ApprovalStatus=APPROVED (double-checked in signing.ts and the sign route).
- An approval pass must complete in one transaction: Signer PENDING→SIGNED + write ApprovalRecord (including fromStatus/toStatus/ip/userAgent); only when the last APPROVER passes does the task become APPROVED and SigningStatus NOT_STARTED→IN_PROGRESS. See approve/route.ts.
- Reject: Signer→DECLINED and write declinedReason/declinedAt; task approvalStatus=REJECTED, signingStatus=NOT_STARTED.
- Sequential-sign rounds use
getCurrentSigners()in tasks.ts; parallel sign allows all current signers. canSign/canApprove must include "state machine allows + it is this person's turn + task not expired". - Expiry always goes through
expireDueTasks()(currently lazy; if a queue is added later, business routes should not need new eligibility logic). - Signing complete: the task is COMPLETED only after every required Signer is SIGNED; write usedAt immediately after a successful external-token POST.
- Changing a transition table requires adding/updating unit tests (
__tests__/, currently 91 cases); illegal paths need assertions.
Standard steps
- Add transition and predicate functions in the matching state-machine file and add tests first (red → green)
- Routes only call state-machine functions; do not inline rules
- Multi-table writes use
prisma.$transaction pnpm test(vitest) and tsc all green
Do not
- ❌ Directly update status fields in a route for convenience
- ❌ Merge approval and signing into one status field
- ❌ Only grey out frontend buttons without backend checks (frontend is display control only)
- ❌ Delete or skip ApprovalRecord rows
Done when
- New transitions have unit tests in the table (at least one legal and one illegal)
- Multi-table changes are in a transaction; IP/UA/reason fields are written
- Sequential and parallel sign paths are both verified
- Frontend canXxx matches backend predicates