DEV Community

Cover image for Approval workflows look simple: 10 edge cases that will break yours
sodastech
sodastech

Posted on

Approval workflows look simple: 10 edge cases that will break yours

Most approval features start as a status column, an approved_by field and a button. That version ships fast and works fine for a while.

The trouble is that approval bugs rarely throw. A step gets skipped, a request goes through, and you find out months later when finance asks who signed off on a payment. So the rule I ended up following for everything below: when in doubt, stop the request and tell someone. Don't let it through.

This is the list I kept while designing an approval engine. Some of these I've seen in real systems. A couple only showed up once I started throwing random tests at the code.

1. The requester is also an approver

A manager submits an expense, and step one is "head of the requester's department". That's the manager. If you just resolve the role and create tasks, they get to approve their own request.

Take the requester out of every step and log that you did it. If a flow really needs self-approval, make it something someone has to switch on.

In the Rungs catalog: EC-01

2. The same person shows up in two steps

Step one is the direct manager, step two is "anyone in finance", and the manager happens to have the finance role too. You can make them approve twice (annoying), or carry the first approval forward and close step two automatically (friendly, and wrong more often than you'd think).

If finance has other people in it, carrying it forward means one person just cleared two checks. That's the thing separation of duties is supposed to prevent. What I settled on: the carried approval counts as their vote, but it can't close the step on its own if someone else could still decide. When they're the only approver for both steps, carrying it over is fine.

In the Rungs catalog: EC-03, EC-47

3. The approver is on leave

"If A is away, give it to B" sounds like a one-line lookup. It isn't:

  • B might be the person who submitted the request. Now you're back at #1.
  • B might also be away and delegating to C. Follow the chain, but put a cap on it.
  • A delegates to B and B delegates to A. Catch the loop, leave the task with A, and log a warning.

The one that's easy to miss: A comes back early and wants the task back from B. A rule like "you can't hand a task to someone who already had it" is handy for stopping ping-pong, but it also blocks this. You need an exception for the person B is standing in for.

In the Rungs catalog: EC-20 to EC-23, EC-60

4. Delegation turns two approvers into one

A step needs both A and C. A goes on leave and delegates to C. Now C has two tasks in the same step, clicks approve twice, and a two-person check has become a one-person check because someone took a holiday.

My rule now: delegation never merges votes. If C already has a vote in that step, skip the delegation and leave A's task where it is. Deadlines and escalation can deal with A being away.

In the Rungs catalog: EC-48, EC-49

5. Nobody is left to approve

The finance role is empty because someone misconfigured it. Or the position is vacant. Or the only approver was the requester, and you removed them (see #1).

"Skip the empty step" is the easy default, and it's an approval bypass. One broken role config and every request sails past finance.

I handle it in two places:

  1. On submit, resolve approvers for every step that's going to run. If any of them comes back empty, reject the submission and say which step.
  2. If a step turns up empty later (the org changed after submit), don't fail the previous approver's click and don't skip. Park the step as blocked and ping an admin so they can reassign, retry or override.

In the Rungs catalog: EC-43, EC-42

6. "7000000" is not 7000000

The finance step has a condition: amount > 5000000. The app sends "amount": "7000000", a string. A forgiving evaluator compares a string to a number, gets false, and skips finance. Seven million goes straight to the director.

Throw instead. A missing field or a wrong type in a comparison should fail the submission with an error that names the field. And check all the conditions when the request is submitted, so it's the requester who sees the error, not some approver halfway down the chain.

In the Rungs catalog: EC-40, EC-41

7. Two clicks at once

Two people in finance open the same request and both hit approve. Or one person double-clicks. Without some care you get two approvals, two emails, maybe a step that advances twice.

Version every request and write with compare-and-set, so one write wins and the other reloads. Add idempotency keys so a retried call returns the first result instead of doing the work again. If you're multi-tenant, scope those keys per tenant, or one customer can end up with another customer's response.

In the Rungs catalog: EC-15, EC-16

8. Resubmitting with a different amount

A 3 million request skips finance, which is fine, that's what the condition is for. The director sends it back for a fix, and the requester resubmits it at 10 million. If the flow picks up again at the director, the finance step never gets another look.

Resuming from the step that sent it back is only safe when the data hasn't changed. If it has, start over and re-check everything, and write down why in the audit log.

In the Rungs catalog: EC-51

9. A timeout approves over a "no"

One approval out of A and C is enough. A rejects, with a reason. The step stays open because C could still approve. Then the deadline passes, the auto-approve timeout fires, and the request goes through. A's rejection just disappears.

Whoever turned on auto-approve meant "nobody objected". Here somebody did. So auto-approve only runs if there's no rejection in the step. Otherwise it falls back to reminders and logs why it didn't act.

In the Rungs catalog: EC-59

10. Escalating to one boss

A step needs three approvals. One comes in, two people let the deadline pass, and the flow escalates to their manager. Does that one manager now cover both missing votes?

If yes, a three-person check just became a two-person check. If no, escalating to one person can never finish the step. Neither feels great. I went with no: escalation replaces missing votes one for one. If there aren't enough people to escalate to, the request stays open, an escalation_failed event goes out and the reminders keep coming. It's the kind of behavior that surprises people, so it's documented in big letters.

In the Rungs catalog: EC-54

A quick self-check

If you've built approvals yourself, these are the questions I'd ask of your system:

  • Can a requester ever end up with a task on their own request?
  • Can one person clear two checks without anyone else deciding?
  • Can delegation, escalation or dedupe shrink the number of different people who decide?
  • Does an empty step, a missing field or a wrong type ever turn into "skip" instead of an error?
  • When the system acts on its own (skip, auto-approve, escalate), does the audit log say why?

Why I care about this

I'm building Rungs, an open-source approval workflow engine for TypeScript. Everything above has a written rule and a test, along with around forty more cases in a public edge-case catalog. There are also property tests that throw thousands of random scenarios at it, which is how I found a couple of these in the first place.

Version 0.1 just went out on npm (npm install @rungsjs/core). It's early and the API can still change, so this is the best time for input:

  • Which of these have you run into?
  • What's missing?
  • Would you have handled any of them differently?

The repo, the docs and the full edge-case catalog are here: github.com/Sodastech/rungs. Leave a comment here or open an issue there, either works.

If you'd rather browse than clone, the docs and the searchable edge-case catalog live at sodastech.github.io/rungs.

Top comments (0)