Approval Workflows as State Machines in a Government E-Services Portal
Verifier then approver was easy until named assignees, parallel review, commissioner sign-off, escalation, incomplete-resubmit loops and cancelling approvals arrived. How SportGate turned its approval flow into versioned configuration, one guard, and reversible transitions on Postgres.

On SportGate, the Maldives portal where sports clubs and associations do their paperwork with the government, every request ends in a human decision. A citizen signs in with their national digital ID and files a request: a name reservation, an organisation registration, a logo and flag, an executive committee, an annual report, a player release, a tournament. A verifier checks it and an approver signs it off. The first version hard-coded "verifier, then approver" into each module's routes, and it was fine until the requirements kept coming. Specific people had to be nominated for specific steps. A commissioner had to sign off before certificates were issued. Some modules needed parallel review. Requests that sat too long had to escalate. Incomplete requests needed a send-back. And an approval occasionally had to be cancelled after the fact.
Each of those changes how a request moves from one status to the next. This is how I restructured the workflow so new requirements became configuration and guarded transitions instead of more if statements spread across a dozen route files.
The statuses are the contract
Everything starts with one shared Postgres enum that the request modules use:
DRAFT, SUBMITTED, UNDER_REVIEW, PENDING_APPROVAL, APPROVED, REJECTED,
CANCELLED, COMPLETED, EXPIRED, RETRACTED, INCOMPLETE, RESUBMITTED
A status enum is a public contract. It drives the applicant's dashboard, the admin queues, SMS templates and reports. So I grow it only by adding values, with ALTER TYPE ... ADD VALUE IF NOT EXISTS in hand-checked migrations. You can't easily remove a value from a Postgres enum. When a type really had to change shape (an old organisation-type enum being replaced), I used the slow, data-preserving route: add a new column, map every old value with a CASE, drop the old column, rename, restore NOT NULL, then verify with a GROUP BY. That's boring, and it doesn't lose data.
Workflow as data: the approval matrix
The key move was taking "who approves what, in which order" out of code and into a table. There is one ApprovalMatrix row per request type:
- •
step1Role/step2Role, plus optionalstep1AdminId/step2AdminIdfor a named individual - •an optional third level (
step3Enabled, role or named admin), used as the commissioner's final sign-off - •
mode:SEQUENTIALorPARALLEL - •
escalationHoursandescalationRole - •a
versioncounter
Admins edit this in a Workflow Settings screen. A new requirement like "only this one officer may verify tournament requests" is a settings change, not a deploy.
Two rules keep it predictable. A named assignment is exclusive: once a specific person is nominated, holding the role isn't enough anymore. And when a module has no matrix row, it falls back to the legacy verifier/approver roles, so a missing row never locks a queue.
One guard every transition calls
Every verify and approve route calls the same function before changing anything. It answers three questions in order:
1. Who may act? The named admin if there is one, otherwise the configured role. Refusals are specific: "This step is assigned to (name). Only they can act on it." A bare 403 sends people to the helpdesk.
2. Has this level already acted? A double click or two officers on the same record shouldn't produce two decisions.
3. Is the order right? Sequential mode requires PENDING_APPROVAL before level 2. Parallel mode accepts either level while the request is open.
The decision about where the request goes next is its own small, pure function:
if (!parallel) return level === 1 ? 'PENDING_APPROVAL' : 'APPROVED';
if (otherLevelDone) return 'APPROVED';
if (level === 1) return 'PENDING_APPROVAL';
return currentStatus; // level 2 went first; wait for level 1
Keeping "may I?" separate from "what happens next?" made parallel mode cheap to add. The guard loosened its ordering rule, and the outcome function learned that a request is only approved once both levels have signed off.
Configuration changes are versioned
If the matrix decides who can approve government requests, changing it is itself something to audit. Saving the matrix runs in a transaction. It compares the incoming fields with the current row, does nothing if nothing actually changed, snapshots the previous state as JSONB into approval_matrix_versions under the current version number, then bumps the version. A unique index on (matrixId, version) stops two concurrent saves from writing the same version. When someone asks "who changed the approver for annual reports, and when?", the answer is one query.
The loops: incomplete, resubmit, auto-cancel
Real requests aren't a straight line. An officer can mark a request INCOMPLETE with remarks. The applicant gets an in-app notification and an SMS carrying those remarks, fixes the original pre-filled form, and resubmits. The request moves to RESUBMITTED, which the guard treats the same as SUBMITTED: it goes back to level 1. Each module table records markedIncompleteAt, markedIncompleteBy, resubmittedAt and a resubmissionCount, with an index on resubmittedAt for the admin queues.
Two design choices here:
- •Notifications never roll back a decision. The SMS and in-app side effects are wrapped so that failures are logged and never thrown. A flaky SMS gateway shouldn't undo an officer's work.
- •Abandoned loops close themselves. A scheduled job cancels
INCOMPLETErequests that have sat untouched past a grace period and tells the applicant. The default is 15 days, stored as a system setting so it can change without a deploy.0turns it off.
Undoing an approval is a transition too
The hardest requirement was cancelling or reopening a request that was already approved. Approval has side effects. Approving an organisation registration creates founders, articles of association, delegations, and sometimes the organisation's logo and flag record. Several of those are unique per organisation, so approving the same request twice would fail unless the first approval's effects are removed.
So cancel (APPROVED → CANCELLED) and reopen (APPROVED or CANCELLED → RESUBMITTED) are handled by one service with a small adapter per request type. Each adapter knows how to find the record, which decision fields to clear, how to reset the module's workflow row, an optional guard, and an optional undo. All of it runs inside one Prisma transaction:
1. load the record and check the transition is allowed from its current status (409 otherwise)
2. run the guard, for example refusing to cancel a name reservation that a live registration is already using
3. undo the approval's side effects, only if it is currently APPROVED
4. write the new status and clear prior decisions on reopen
5. append an approval-log row with previousStatus, newStatus, the actor and a required reason, plus an audit-log row
The approval log is append-only and indexed by request id, reference, admin, action and time. That makes it the request's history: the action history officers see on each request reads from it.
Gates after approval
Approval isn't always the end. When a module turns on its third level, a certificate can't be generated until the commissioner has signed off. Turning that on is a deliberate configuration change, not a breaking upgrade. Separately, certificates got an explicit release step, so an officer can replace a bad signed scan before the applicant sees it. The migration that added the release columns also backfilled every already-uploaded certificate as released, so no applicant lost access to a document they could already download.
Time-driven transitions
Some transitions are caused by time passing, not by people. Scheduled endpoints escalate stale approvals to the configured role, cancel stale incomplete requests, expire name reservations and expire executive-committee terms. The escalation finder is read-only. It returns candidates, and the cron route decides what to do with them, which keeps it easy to test.
What I'd do differently
Each request type has its own table with its own copy of the decision columns (verifiedAt, approvedAt, rejection fields, incomplete fields). That's why cancel/reopen needs eight adapters, and why every new workflow feature turns into a migration across five or more tables. If I were starting again, I'd keep the module data in module tables but put the workflow state in one shared workflow-instance table keyed by request type and id. I'd also type the status checks against the enum instead of string literals.
Takeaway
Treat approval workflows as state machines even if you never pull in a state-machine library. Keep statuses as a small contract that only grows. Put who-approves-what in versioned data. Send every transition through one guard and one outcome function. Make reversals real transitions with guards, undo steps and a required reason, all in one transaction. And write every decision to an append-only log, because in government work, being able to explain how a request got here is a requirement.
Related Posts

Movable Placement Needs Shared Data: Database Rules for a Swarm Cluster
Two production databases on our Swarm cluster were one reschedule away from coming back empty. The placement-vs-storage rule that fixes it, the 4.6x fsync cost of putting MySQL on GlusterFS, and the traps (and backups) that actually matter.

Building ERP Systems That Last: Hard-Won Lessons From Payroll, POS and Rent Ledgers
A kiosk said "attendance marked" for eight months while 3,131 punches went nowhere. Lessons from building an HR/payroll suite, a multi-tenant POS and a rental ledger: tenancy, snapshots, single-source rules, replayed approvals, append-only audit and safe repairs.