Skip to content

Catalogue Group Approvals Design

Ed Mozley edited this page Oct 27, 2026 · 1 revision

Catalogue group approvals — design (#220)

The request, the decisions taken, and how each acceptance criterion is met. Users: Catalogue Request Approvals. Developers: Catalogue approvals — developer guide, section 8.


The request

#220 (Dr4inder) asked for a catalogue form's approval to come from a group, with a configurable number of distinct approvals - "any one of the team" or "two different members" - while the existing single named approver carries on unchanged. It asked for the approver / designer separation from #180 to be kept, for requester exclusion, for an administrator's override to be explicit and audited rather than counting as votes, and for empty or too-small groups to block clearly rather than wave a request through.

The shape

  • The group is a Team (People → Teams). Native membership, no new identity store, as the request suggested.
  • One extra choice in the existing window. Approval settings gains Who decides: A person / A team. A person is the window as it was.
  • Same path, same inbox. Team requests go through catalogueApprovalDecide(), the same inbox, the same ticket raise, the same When approved action list and the same workflow events. No second approval framework.
  • Nothing changes on upgrade. Every new column defaults to today's behaviour; a form is only in team mode once somebody picks a team.

Decisions

The request left some points open. These were put to Ed, and his answers are what was built.

Question Decision Why
What does one rejection do? Configurable per form: Rejections needed to decline, default 1 The request's suggestion (one rejection concludes it) is the default. Some teams will not want one person able to block a purchase on their own, so the number can be raised. Not tied to the approvals number - "2 to approve, 1 to decline" is the commonest four-eyes setup
Does an administrator's click count as a vote? No. A separate Override, reason required, recorded As the request said: an admin passing by must not quietly become one of "two different people". An admin who is a member of the team votes like anyone else
Exclude the requester? Always, in team mode. A tick box, default off, in person mode Team mode is new, so it can be strict from the start. Person mode already exists, and turning exclusion on for it unasked could strand requests on an upgrade
Requests nobody can decide? Flagged Stuck, never decided automatically. An administrator can Move it (to another team or person) or Override, each with a reason, each recorded The request: "never skip the gate or automatically approve because nobody can decide" and "administrative repair/reassignment must be explicit and audited"
Membership changes while a request waits Checked at every vote. Leavers lose their vote; joiners gain one; votes already cast still count Membership is how an organisation says somebody should no longer decide things
Policy changes while a request waits Snapshotted at submission. Form changes affect new requests only The request, and the rule the named approver already followed
After a Move A new round. Earlier votes stay on the record but stop counting The new approvers were not the people the earlier votes were cast alongside

The request's acceptance criteria

# Criterion How it is met
1 N distinct approvals from one group, including N = 1 and N = 2 ✅ Approvals needed, 1 to 20. One vote per person per round, enforced by a unique key in the database
2 The requester cannot approve; one person's repeats do not count twice ✅ The requester is matched by email (a member of staff requesting through the portal) and cannot vote. A second vote by the same person is refused: You have already decided this request
3 Membership, access and thresholds enforced on the server ✅ Every rule is in catalogueApprovalDecide(), behind the API; the inbox only hides buttons that would be refused anyway. Tested by calling the engine directly as a non-member, the requester, a repeat voter and a plain administrator
4 Progress and individual decisions visible and auditable; approval-only access kept ✅ The card shows the team, 1 of 2 approvals, and each vote (who, when, note). Votes, moves and overrides are kept in their own tables. Team members get the same approval-only access as a named approver (#180) - the inbox without the designer or other submissions
5 Approved actions run only at the threshold, exactly once, despite concurrent votes or retries ✅ The decision holds a row lock on the request (SELECT ... FOR UPDATE). The test fires the two final votes from two processes at once and checks one ticket. A retried vote is refused as a repeat. A gated request still fires catalogue_request.submitted, not form.submitted, so nothing runs before approval
6 Explicit outcomes for unavailable quorum and rejection; policy changes do not rewrite pending requests ✅ Stuck when neither number can be reached (empty team, too few independent members, a split vote with nobody left). Rejection needs a reason and runs no approved actions. Policy is snapshotted
7 Named-person approvals and upgrades unchanged until team mode is chosen ✅ No data migration; every default is today's behaviour. Before Database Verification the new columns are not read at all. The test checks a pre-upgrade form row still gates on its named approver

The regression list the request asked for - requester exclusion, duplicate votes, membership changes, unavailable quorum, concurrent final approvals - is all in tests/catalogue-group-approvals.php (see Developer tests - Forms).

Not done

  • Several stages ("line manager, then finance"). One request, one policy.
  • Routing to the requester's manager. Portal users still have no manager relationship.
  • A notification per team member. catalogue_request.submitted carries the team, so a workflow rule can tell them; there is no built-in email.
  • The rejection reason on the portal. As before, the customer sees Declined without the note.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally