Approvals and Evidence 1 Change Approvals - edmozley/freeitsm GitHub Wiki
Approvals and evidence 1 - enforceable change approvals
Status: built in 3.8.0. Part 1 of the #180 design, kept as the record of what was decided and why. Where the build differs from what is written here, the overview says so. Anything marked today describes 3.7.0.
Updated 16 Oct 2026 after the requester's follow-up on #180: a vote is now tied to the version on the voter's screen (Β§5); a submitted change cannot be deleted, only cancelled (Β§4); and standard changes are pre-authorised by an approved template, not by their type (Β§8).
What #180 asks for:
- When enforcement is on, a change cannot enter Approved or a later stage without its approval requirement being met, through every write path, UI and API.
- An analyst cannot approve their own change through a status edit; changing who approves must follow the configured permissions.
- The approval identifies the version that was reviewed; a material edit needs a fresh approval.
- Exceptions (pre-authorised standard changes) are explicit and traceable, not a quiet status change.
1. How a change gets approved today
Every write to a change goes through one class, ChangesService in includes/services/changes.php, which both the UI endpoints and REST API v1 call (Service Layer). That is good news: there is exactly one place to put a rule.
| Path | Calls | What it lets you do to the status today |
|---|---|---|
Edit form β Save (api/change-management/save.php) |
createChange() / updateChange() |
Anything. The status dropdown lists every active status |
Right-click a change β Status (setChangeStatusFromContext() in assets/js/change-management.js) |
updateChange() via api/change-management/save.php |
Anything |
Table view inline edit (api/change-management/update_field.php) |
updateChange() |
Nothing - status is deliberately not on its whitelist |
CAB vote (api/change-management/submit_cab_vote.php) |
voteCab() |
Pending Approval β Approved (threshold met) or β Draft (a required Reject) |
REST POST /changes, PATCH /changes/{id} |
createChange() / updateChange() |
Anything, including creating a change already Completed |
REST POST /changes/{id}/cab/vote |
voteCab() |
As the UI vote |
Create a change from a ticket (assets/js/inbox.js) |
createChange() via api/change-management/save.php |
Default status only |
Analyst offboarding (includes/analyst_lifecycle.php) |
updateChange() |
Assignee only |
Demo data import (api/system/import_demo_data.php) |
direct INSERT |
Seeds demo rows; not a user path |
Nothing else writes changes.status_id. No workflow action targets changes, there is no bulk edit, and the calendar is read-only.
updateChange() has no transition rules. The only approval logic in the product is the CAB threshold inside voteCab(), and it only runs while the status is literally named 'Pending Approval'.
The named Approver (changes.approver_id) is a label. They have no button, and the help page's "Approver or CAB signs off" is not true for them.
2. The idea in one paragraph
An approval stops being a status somebody picked and becomes a record: a round that opens when a change is submitted, carries a snapshot of exactly what was submitted, collects votes or a decision, and closes as approved, rejected, withdrawn or superseded. The status follows the round, not the other way round. In Record mode (the default) nothing is refused - rounds are simply kept, including for a status set by hand, so the history is honest. In Enforce mode the status cannot reach an approved stage unless the current round is approved, and the check sits in ChangesService, which every path already goes through.
3. Statuses get a stage, not a name
Today the code identifies statuses by their English name, and Settings β Statuses lets anybody holding changes.statuses rename them (F3 on the overview). Enforcement built on names would be one rename away from switching itself off.
So each status gets a stage, chosen in Settings β Statuses:
| Stage | Means | Seeded statuses | Enforce mode: may a change enter it without a current approval? |
|---|---|---|---|
draft |
being written | Draft, Submitted | yes |
awaiting |
submitted; a round is open | Pending Approval | yes - entering it opens the round |
approved |
authorised, not started | Approved | no |
implementing |
the work is happening | Scheduled, In Progress | no (Emergency route excepted) |
done |
finished, closed | Completed, Failed | no (Emergency route excepted) |
stopped |
ended without implementing, closed | Rejected, Cancelled | yes, always |
// includes/db_verify_schema.php - change_statuses
'stage' => 'VARCHAR(20) NULL', // draft|awaiting|approved|implementing|done|stopped; NULL = not yet classified
Upgrade: on the Database Verify run that adds the column (the one-time probe pattern used for is_admin), each status is classified from its seeded name, exactly as the table above. A status whose name matches none - a custom "Peer review", or Approved renamed to Authorised - is left NULL.
NULL is handled loudly, not guessed. Settings β Statuses shows an amber "Choose a stage" marker on it, and Enforce cannot be switched on while any active status has no stage. The alternative, defaulting an unknown status to draft, would quietly turn "Authorised" into a status anybody could set - the one outcome enforcement exists to prevent.
Which status does the code move a change to? The first active status, by display_order, in the right stage: approved after a successful vote, awaiting on a resubmission. The CAB rejection target is a setting (Β§5). Two rules keep that well-defined: each of the draft, awaiting and approved stages must keep at least one active status (api/change-management/delete_change_status.php and api/change-management/save_change_status.php refuse the last one), and is_closed is set automatically for done and stopped.
Every name literal goes:
| File | Today | Becomes |
|---|---|---|
includes/services/changes.php |
=== 'Approved', 'Pending Approval', $statusIdFor('Draft') |
stage === 'approved', changeStatusForStage($conn, 'awaiting') |
api/change-management/get_approvals.php |
cs.name = 'Pending Approval' Γ4 |
cs.stage = 'awaiting' |
assets/js/change-management.js |
c.status === 'Pending Approval' (vote panel), 'Completed' || 'Failed' (PIR) |
c.status_stage === 'awaiting', === 'done' |
includes/watchtower_queries.php |
cs.is_default = 0 as "submitted" |
cs.stage = 'awaiting' |
This slice (A) is worth shipping on its own: it fixes F3 for everybody, whatever happens to the rest.
4. The two modes
Settings β Approvals β Approval rules:
| Record approvals (default) | Enforce approvals | |
|---|---|---|
| Who it is for | Teams who use changes as a record and trust each other | Teams who need the system to be the control, as in #180 |
| Setting a status by hand | Allowed, as today | Refused into approved, implementing or done without a current approval (409 approval_required) |
| A hand-set Approved | Recorded as a round with route manual, attributed to whoever set it, and approval_datetime stamped (fixes F1) |
Not possible - approval comes from a vote or a decision |
| Approving your own change | Allowed, but the round is marked not independent and shows so | Refused (unless Approving your own change = Allowed) |
| Editing a material field after approval | A setting - default: approval kept, marked "approved against an earlier version" (Β§6) | Approval superseded; the change goes back to awaiting approval |
| Editing the CAB roster or approver after submission | Allowed, audited | Refused outside the Draft stage |
| A vote from a page opened before a material edit | A setting - default: recorded and marked "cast against an earlier version" | Refused, always (Β§5) |
| Deleting a change that has been submitted | A setting - default: allowed, as today | Refused, always - cancel it instead (below) |
Why two modes and not a dozen toggles. Every one of the Enforce rules on its own is a way round the others. Enforcement without the roster lock is beaten by adding yourself to the board; without re-approval on edit, by approving an empty plan and filling it in later. They are one control, so they are one switch. The settings that do stay separate (independence, the override, which fields are material) are the ones where a small team genuinely needs a different answer.
Deleting a change
Added after the requester's follow-up on #180. An audit can come months after the work. By then, the changes that were rejected, withdrawn or abandoned explain as much as the ones that were done.
Today there is one way to delete a change: ChangesService::deleteChange(), behind api/change-management/delete.php and DELETE /changes/{id}. It is a hard delete. The attachment files are unlinked, and ON DELETE CASCADE takes the audit trail, comments, CAB roster and links with it. With this design it would also take the rounds, votes and revisions, which is all the evidence there is.
"Submitted" means the change has at least one approval round, of any route or state. That includes the legacy rounds written on upgrade (Β§8), so changes approved before the upgrade are covered too. A draft that was never submitted has no rounds, so it can always be deleted, and a change raised by mistake can still be cleaned up.
The setting Deleting a change that has been submitted:
| Record mode, Allowed (default, today) | Record mode, Not allowed | Enforce mode | |
|---|---|---|---|
| Delete a never-submitted draft | yes | yes | yes |
| Delete a submitted change | yes - and everything goes with it, as today | refused | refused - always |
As with stale votes, Enforce has no choice. If an approval can be deleted along with its change, the record is only as good as the most careless person with Change Management.
Refused is 409 change_has_approval_history: "This change has approval records, so it can be cancelled but not deleted." In the UI, Delete gives way to Cancel. Cancel moves the change to the first status in the stopped stage, closes any open round as withdrawn with the reason typed, and leaves everything else exactly where it was. A cancelled change is closed, so it leaves the open lists, but it stays in search, in its history and in the evidence export (Part 2 Β§6), which works the same for a rejected, withdrawn or cancelled change as for a completed one.
Belt and braces in the schema. change_approvals.change_id is ON DELETE RESTRICT, not CASCADE. A direct DELETE FROM changes, or any future code path that forgets the rule, then fails instead of silently wiping approval records. When deletion is allowed, deleteChange() removes the rounds and votes itself first, inside its transaction, so behaviour is still exactly as today.
5. Rounds, votes and decisions
The tables
CREATE TABLE change_approvals ( -- one row per approval round
id INT AUTO_INCREMENT PRIMARY KEY,
change_id INT NOT NULL, -- FK changes ON DELETE RESTRICT - deliberately NOT cascade (Β§4, Deleting a change)
round_no INT NOT NULL, -- 1, 2, 3β¦ per change
route VARCHAR(20) NOT NULL, -- cab | approver | pre_authorised | emergency | manual | override | legacy
state VARCHAR(20) NOT NULL, -- open | approved | rejected | withdrawn | superseded
is_independent TINYINT(1) NULL, -- NULL until decided; 0 = somebody approved their own change
content_hash CHAR(64) NULL, -- sha256 of the material fields at submission
snapshot LONGTEXT NULL, -- JSON: short material fields by value, long ones by revision id
opened_by_id INT NULL, opened_datetime DATETIME NOT NULL,
decided_by_id INT NULL, decided_datetime DATETIME NULL, decision_note TEXT NULL,
closed_reason VARCHAR(255) NULL, -- why withdrawn / superseded, in words
UNIQUE KEY (change_id, round_no)
);
CREATE TABLE change_approval_votes ( -- every vote ever cast, never updated
id INT AUTO_INCREMENT PRIMARY KEY,
approval_id INT NOT NULL, -- FK change_approvals ON DELETE CASCADE
analyst_id INT NULL, -- FK analysts ON DELETE SET NULL (a vote outlives its voter)
is_required TINYINT(1) NOT NULL,
is_counted TINYINT(1) NOT NULL, -- 0 when the voter is one of the change's own people
vote VARCHAR(20) NOT NULL, -- Approve | Reject | Abstain
comment TEXT NULL,
content_hash CHAR(64) NULL, -- the version the voter's page showed, SENT BY THE BROWSER (Β§5); NULL = an API call that did not say
was_current TINYINT(1) NULL, -- 1 = that version was still current when the vote landed; 0 = cast from an out-of-date page
voted_datetime DATETIME NOT NULL
);
And on changes: approval_id INT NULL - the current round, open or approved, or NULL.
change_cab_members keeps its shape. Its vote / vote_comment / vote_datetime columns become "this member's vote in the current round" and are cleared when a new round opens (fixing F2). That keeps GET /changes/{id}/cab byte-identical in shape. The append-only change_approval_votes is the history. Two writes per vote, one of them a log - the log is never read to decide anything, so the two cannot disagree in a way that matters.
A round's life
submit (enter "awaiting" stage)
Draft ββββββββββββββββββββββββββββββββββββββββββββββββββΆ round N: open
β² β β β
β back to Draft threshold met / β β β a material edit (Enforce)
β (round β withdrawn) approver approves β β ββββββββΆ round N: superseded
β βΌ β + round N+1: open
β round N: approved β
β approval_datetime β a required Reject /
β status β "approved"β approver rejects
β βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββ round N: rejected
status β the rejection target
Which route a round takes
Worked out when the round opens, from the change and its type:
// includes/changes/approvals.php
function changeApprovalRoute(PDO $conn, array $change, array $type, array $cfg): string {
if (changeTemplatePreAuthorises($conn, $change, $cfg)) return 'pre_authorised'; // Β§8 - an approved template, untouched
if ($type['approval_route'] === 'emergency') return 'emergency';
if (!empty($change['cab_required'])) return 'cab'; // the board votes
if (!empty($change['approver_id'])) return 'approver'; // one person decides
return 'none'; // Record: fine, a manual approval will be recorded. Enforce: submission refused.
}
So the existing fields finally mean something: Require CAB review chooses a vote, and Approver gets an Approve / Reject of their own (new endpoint, Β§13). In Enforce mode a change with neither cannot be submitted: "Choose an approver or tick Require CAB review before submitting."
The CAB threshold - unchanged, except what counts
voteCab() keeps today's arithmetic exactly - all = every required member approves; majority = more than half the required members; one required Reject rejects; Abstain never moves anything; optional members never count. Two differences:
- A vote needs an open round. No open round β 409 "This change is not awaiting approval." Today a vote on a Draft is accepted and then counts later, against content that may have changed since. The UI never offered it (the panel only shows in Pending Approval), so this only affects API callers.
- A vote from one of the change's own people is not counted in Enforce mode with independence on (Β§7). It is still recorded, with
is_counted = 0, so the board's discussion is complete.
When the required members cannot reach the threshold - for example every required member is the change's own person - Enforce mode refuses the submission with that reason rather than opening a round nobody can close.
Where a rejection goes is the setting When a required CAB member rejects: any status in the draft or stopped stage. Default Draft, which is today's behaviour; a team that wants a rejection to be final picks Rejected.
A vote is cast against the version on screen
Added after the requester's follow-up on #180. An approver opens a change, reads the plan and leaves the tab open. Meanwhile somebody edits the test plan. If the approver then clicks Approve, the record must not say they approved a test plan they never saw.
The version travels with the page. api/change-management/get.php (and GET /changes/{id}) already return the current round; they also return approval.current_version, the sha256 that changeApprovalContentHash() works out from the change's material fields as they stand (the same function that writes a round's content_hash, Β§6). The Approve / Reject / Abstain buttons send back the two values the page was drawn with: approval_id and content_hash.
The server compares, inside the locked transaction (below). The vote is out of date when either:
approval_idis not the change's current open round - it was superseded, withdrawn or decided while the page was open; orcontent_hashis not the current version - a material field was edited since the page loaded.
A non-material edit (a typo in the title, a new assignee) does not change the version, so it never makes a vote out of date.
What happens to an out-of-date vote is the setting A vote from an out-of-date page:
| Record mode, Record it and mark it (default) | Record mode, Refuse it | Enforce mode | |
|---|---|---|---|
| The vote | recorded, was_current = 0; the round's history shows "Cast against an earlier version - see what changed" |
refused | refused - always |
Why Enforce has no choice. An approval in Enforce mode is the control. A switch that let it be cast against content the voter never saw would undo the point of Enforce, so the setting is greyed out there, with that reason.
Refused means 409 approval_stale: "This change was edited after you opened it. Read the new version before you vote." The page then:
- reloads the change and shows what changed since the version the voter had - the Part 2 comparison, between the revisions in the version they saw and the current ones (Part 2 Β§5);
- keeps the comment they had typed;
- re-arms the buttons against the new round and version, so voting again is one click, made knowingly.
The stored content_hash is the browser's, not the server's. That is the whole point: the record names the version the person was shown. With the comparison above, in Enforce mode it is always also the current version. In Record mode, was_current says whether it was.
The same check applies to the named approver's Approve / Reject (decideApproval()) and to the administrator override - anything that decides a round.
API callers. POST /changes/{id}/cab/vote and POST /changes/{id}/approval/decide accept approval_id and content_hash, copied from GET /changes/{id}. In Enforce mode, or with Refuse it, both are required (400 missing_field without them); a script cannot approve without first reading what it approves. In Record mode with the default they stay optional, so existing integrations keep working, and a vote without them is stored with content_hash = NULL, which the history shows as "version not stated (API)".
π TRAP for the build: the page must send the version it was drawn with, held from the moment the detail loaded. It must never re-fetch the change just before posting the vote. That would quietly turn the check into "approve whatever is current", which is the exact bug this section exists to prevent.
One transaction, one lock
Today none of this is transactional (F5). Every write that can move an approval runs as:
$conn->beginTransaction();
try {
$row = self::lockChange($conn, $ctx, $changeId); // SELECT β¦ FOR UPDATE, with the company check
// β¦ the vote / decision / update, its revisions, its audit rows, the round β¦
$conn->commit();
} catch (Throwable $e) {
$conn->rollBack();
throw $e;
}
self::afterCommit($events); // workflow dispatch only after the commit, never inside it
The row lock is what makes "two last votes land together" safe: the second waits, then sees the round already approved and records its vote without a second transition. Workflow dispatch stays outside the transaction, as Service Layer #10 requires - a rolled-back email is still an email.
6. Material edits and re-approval
A round remembers exactly what it was asked to approve. When it opens, the snapshot records:
- the short material fields by value - type, impact, risk likelihood, risk impact (and the work window, if ticked);
- the long material fields by the id of their current revision in
change_text_revisions(Part 2).
content_hash is the sha256 of that snapshot, serialised canonically. Every vote also stores the hash it was cast against.
Which fields are material is the tick list Edits that need a fresh approval. Defaults: description, reason for change, risk evaluation, risk likelihood, risk impact, test plan, rollback plan, change type, impact. Off by default: title (typo fixes), priority, assignee, the work window, the outage window. The PIR fields are never offered: they are written after the work, and a PIR that reopened an approval would be absurd.
The work window is a genuine judgement call. Some boards approve "this change", some approve "this change on Saturday night". That is why it is a tick box.
What happens on a material edit (updateChange(), inside its transaction, after it has worked out what changed):
| Current round | Record mode | Enforce mode |
|---|---|---|
| none | nothing | nothing |
| open | round kept; history marks "edited while awaiting approval" | round β superseded ("Test plan edited by Sam"), member votes cleared, round N+1 opens with a fresh snapshot, change.resubmitted fires so the board knows to look again |
| approved | depends on the setting below | round β superseded, approval_datetime cleared, status β first awaiting status, round N+1 opens |
In Record mode, an edit after approval is a setting (decided 16 Oct): When a change is edited after approval:
| Choice | What happens |
|---|---|
| Keep the approval and mark it (default) | round kept, approval kept; the change shows "Approved against an earlier version - see what changed" |
| Withdraw the approval | as in Enforce mode: round β superseded, approval_datetime cleared, status β first awaiting status, round N+1 opens. Nothing is ever refused, so it is still Record mode |
Why Keep is the default. Clearing the approval puts the change back on Watchtower as awaiting approval and unticks any Projects gate item that depends on it. That is a visible behaviour change on upgrade, which Record mode promises not to make. The marker is enough to be honest: the evidence shows what was approved and what changed afterwards. A team that wants an edit to need a fresh approval, without the rest of Enforce, picks Withdraw. In Enforce mode the setting is greyed out, because there an edit always withdraws.
Why Enforce re-submits automatically rather than refusing the edit. Refusing would force people to drag the change back to Draft, edit, and resubmit - three steps that produce the same result, and teach people that the system is in the way. The edit is the resubmission. The person editing is told before saving: "Saving this will withdraw the approval and send the change back to the board."
π Supersede, never delete. A superseded round keeps its snapshot and every vote. The history reads "Approved 3 Oct by the board β test plan edited 4 Oct β re-approved 5 Oct", which is exactly the trail #180 wants and exactly what a quiet overwrite would destroy.
7. Independence - approving your own change
Who counts as the change's own people is a tick list: Requester, Assigned to, Created by, all ticked by default. Taken at the moment of the decision, from the change row.
| Record mode | Enforce mode, Not allowed (default) | Enforce mode, Allowed | |
|---|---|---|---|
| Their CAB vote | counted; round marked not independent | recorded, not counted | counted |
| They are the named approver deciding | allowed; round marked not independent | refused - "You are the requester of this change, so you cannot approve it." | allowed |
| A manual move to Approved | allowed; round manual, not independent |
not possible in Enforce mode at all | not possible |
Allowed exists for the one-person IT department, where independence is impossible and the honest answer is a record that says so. Even then the round's is_independent = 0 is stored and shown, so nobody reading the history later is misled.
Administrators get no exemption. is_admin bypasses Layer 2 permissions everywhere else; it must not bypass separation of duties, or "make me an admin" becomes the way round it. The administrator's only route is the explicit override in Β§8.
8. Exceptions that stay visible
Each exception is a route on the round, so it shows up in the history, the evidence export and the API exactly like an ordinary approval - with a different word on it.
Standard changes - from an approved template
Rewritten after the requester's follow-up on #180. The first draft pre-authorised a change because of its type: pick Standard and the change skipped review. The requester's point was that picking a type does not show that a particular change is inside the scope that was approved. So pre-authorisation now comes from a template that was itself approved. The by type route has been dropped. Nothing ships that would later have to be taken away.
In plain terms. A template is a recipe: "Restart the print spooler on a print server", with its reason, risk, test plan and rollback plan written once. Somebody responsible reads it and approves it once. After that, a change raised from the recipe, and not altered, is approved by that earlier decision, and its record says so: "Pre-authorised by template 'Restart print spooler' v3, approved by Sam on 2 Oct." If anybody changes the recipe's wording on their change, it is not that recipe any more, so it goes to normal review.
The master switch. Settings β Approvals β Standard changes from approved templates: Off (default) / On. While it is off, templates can still be written and used to pre-fill a change, which saves typing. They never pre-authorise anything. A team that wants this route kept off, as the requester does for its controlled workflow, simply leaves it off.
What a template holds
Settings β Templates (a new tab, behind a new capability changes.templates). The unused change_templates table already has most of the columns (type, priority, impact, category, the two risk scores, and the description, reason, risk, test plan and rollback plan texts). It gains:
| Scope | "Use this template forβ¦" and "Do not use it forβ¦", in words. This is what the approver of the template is approving, and it is printed on every change raised from it |
| Completed per change | ticks, one per field: which fields the person raising the change fills in. Default: description only (which server, which site). Every other field is fixed. The template's approver sees exactly which fields are left open |
| Who may use it | Everyone with Change Management / Only these teams - the restricted selection the requester asked for |
A template has versions, and each version is approved
CREATE TABLE change_template_versions ( -- one row per version; never edited once approved
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL, -- FK change_templates ON DELETE RESTRICT
version_no INT NOT NULL, -- 1, 2, 3β¦ per template
state VARCHAR(20) NOT NULL, -- draft | approved | superseded | retired
content LONGTEXT NOT NULL, -- JSON: every field, the scope, the per-change ticks, who may use it
content_hash CHAR(64) NOT NULL, -- sha256 of the FIXED fields, serialised canonically
created_by_id INT NULL, created_datetime DATETIME NOT NULL,
approved_by_id INT NULL, approved_datetime DATETIME NULL, approval_note TEXT NULL,
UNIQUE KEY (template_id, version_no)
);
- Approving a version needs the new permission Approve standard change templates (
changes.templates_approve). The rules are the same as for any approval: the version's author cannot approve it, an administrator gets no exemption, and Approving your own change = Allowed is the one-person-team escape here too, recorded as not independent. The note is free text, so "Approved at CAB, 12 Oct, minute 4" goes on the record. - Editing an approved template creates version N+1 as a draft. Version N stays usable until N+1 is approved, then becomes
superseded. A template is never edited in place, so every change can point at the exact words that pre-authorised it. - Retiring a template stops new changes using it. Changes already approved by it keep their approval, because it was valid when given.
Raising a change from a template
New change β From a template lists the approved templates this analyst may use, each with its scope. Choosing one fills in the change. The change records changes.template_version_id.
At submission (when the change leaves the Draft stage), changeTemplatePreAuthorises() returns true only if all of these hold:
- the master switch is On;
- the change has a
template_version_id, and that version is stillapproved(not superseded or retired); - the person submitting may use the template (Who may use it), checked at submission and not only when it was chosen;
- the change's fixed fields still hash to the version's
content_hash, so nobody has altered the recipe.
If all four hold, a round is recorded immediately as route = pre_authorised, state = approved, with the template version in the snapshot and in decision_note. The change may go straight to Scheduled or In Progress.
If any one fails, the change takes the normal route (CAB or approver) and says why: "Raised from 'Restart print spooler', but the rollback plan was changed, so it needs normal approval." Nothing is refused, and nothing is approved that should not be.
After submission, editing a fixed field is a material edit like any other (Β§6). In Enforce mode the pre-authorised round is superseded and the change goes to normal review. Fields marked completed per change are not compared with the template, but if they are ticked as material they still need a fresh approval when edited, as on any change.
Evidence. The evidence export (Part 2 Β§6) prints, for a pre-authorised change, the template version in full: its scope, who approved it, when, and the note. That answers the auditor's question "what made this change pre-approved?" with a document, not a word.
Emergency
How it is approved = Emergency - approve afterwards. The change may enter implementing and done without an approved round. A round still opens on submission (CAB or approver, as normal) with route = emergency, and stays open until decided. Watchtower gains a count: Emergency changes awaiting retrospective approval. Whether such a change may reach Done before the decision is open question 7.
Administrator override
Off by default. When on, an administrator sees Approve with override on a change awaiting approval. It requires a written reason, records route = override, decided_by_id, the reason, is_independent as computed, and fires the same change.approved event. It is never silent and never the default action.
Legacy
On upgrade, every change already in an approved, implementing or done stage gets one round: route = legacy, state = approved, decided_datetime = approval_datetime (often NULL), no snapshot. It reads "Approved before FreeITSM kept approval records - no detail available." Without it, switching Enforce on would strand every in-flight change. Changes already Pending Approval get an open legacy round with their existing votes copied across, so voting carries on where it was.
9. Who can set up the approval
#180: "Changes to the approval requirement or approver assignment must also follow the configured permissions."
The approval set-up of a change is: Require CAB review, all / majority, the CAB roster (and who is required), and the Approver.
- Locked once submitted (Enforce). Outside the
draftstage these fields cannot be changed (409 "Withdraw the change to Draft to change its approvers"). Withdrawing closes the round aswithdrawn, so a mid-vote reshuffle is visible. This closes F4. - Who may set them in Draft is the setting Who can choose a change's approvers: anyone with Change Management (default, today), or only holders of the new everyday-work permission Choose approvers (
changes.choose_approvers, Part 3 Β§2). Somebody without it can still raise and edit changes; the approval fields are read-only for them, and a team lead or change manager fills them in. - Independence still applies to the board itself: adding yourself to your own change's CAB is allowed (you may want to see it), but your vote will not count.
In Record mode, roster and approver edits stay open as today, and every one is audited as it already is.
10. The one place the rule lives
All of this hangs off a single function, called from createChange(), updateChange(), saveCab(), voteCab() and the new decideApproval(), inside their transactions:
// includes/changes/approvals.php - TRAP: the ONLY place a status move is judged. Every
// ChangesService write that can change status_id must call it, or that path is a way round.
function changeApprovalGuardMove(PDO $conn, array $change, ?array $round, array $from, array $to,
ActorContext $ctx, array $cfg): void
{
if ($cfg['mode'] !== 'enforce') return; // Record: never refuses
if ($to['stage'] === null) {
throw new ServiceError('conflict', 'status_unclassified',
"The status '{$to['name']}' has no stage. Ask an administrator to set one in Settings β Statuses.");
}
if (!in_array($to['stage'], ['approved', 'implementing', 'done'], true)) return; // draft/awaiting/stopped
if ($round && $round['state'] === 'approved') return; // approved and still current
if ($round && $round['route'] === 'emergency' && $to['stage'] !== 'approved') return;
throw new ServiceError('conflict', 'approval_required',
"This change has not been approved, so it cannot move to '{$to['name']}'.");
}
409 conflict rather than 403: the caller is allowed to change this change; the change is in the wrong state. That is the same distinction the API already draws for "already voted".
π The UI is told, not trusted. The status dropdown and the right-click menu offer only the statuses the guard would accept (
api/change-management/get.phpreturnsallowed_statuses), so people are not shown choices that will be refused. That is for comfort. The guard is what enforces it, and the API, which has no dropdown, meets the same guard.
What a test must prove: with Enforce on, every row of the table in Β§1 that can change a status is refused into an approved stage - the edit form, the right-click menu, PATCH, and POST creating a change straight into Completed. A guard that covers four paths out of five is a guard with a door in it.
11. Events
Added to WorkflowEngine::availableTriggers() and dispatched from ChangesService - both halves, or the event can never be heard (the trap that hid three catalogue-approval events, Catalogue Request Approvals - Developer Guide):
| Event | When | Payload adds |
|---|---|---|
change.submitted |
a round opens | round {no, route}, notify_ids = the required members or the approver |
change.resubmitted |
a round is superseded and a new one opens | round, reason, notify_ids |
change.rejected |
a round is rejected | round, decided_by |
change.approval_withdrawn |
back to Draft, or superseded after approval | round, reason |
change.approved (today) |
unchanged trigger; now also fired for pre_authorised, override and manual |
round.route, so a rule can tell them apart |
change.submitted is what finally makes the help page's "reviewers are notified to vote" true: one workflow rule, on change.submitted, email notify_ids.
12. What people see
- Change detail - an Approval panel replacing the CAB panel: the current round (route, who is still to vote, who has, whether each vote counts and why not), and a collapsed Previous rounds list. A superseded round links to "what changed since", a diff of its snapshot against the current revisions (Part 2 Β§5).
- The approver gets Approve and Reject buttons with a note, like a CAB member.
- Approvals page - unchanged filters, plus Awaiting my decision covering both voters and named approvers, and Emergency - decide afterwards.
- Editor - in Enforce mode, approval fields read-only outside Draft, with the reason; a confirm before a material edit to a submitted change.
- Settings β Statuses gains a Stage column; Types gains How it is approved (Needs review / Emergency - approve afterwards); Approvals and Templates are new tabs.
- New change gains From a template; a change raised from one shows the template's name, version and scope, and, if it lost pre-authorisation, which field was changed.
- Delete becomes Cancel on a submitted change when deletion is not allowed.
- An out-of-date vote opens a "This change was edited after you opened it" box with the comparison, the typed comment kept.
- Help page - the Lifecycle, CAB review and Change types sections rewritten to say what is true in each mode, plus a new Approval rules section.
13. REST API
Changes in Record mode (the default)
Additive, apart from two deliberate behaviour fixes:
| Before | After | |
|---|---|---|
PATCH status β Approved |
approval_at stays null |
approval_at set; a manual round recorded (F1) |
POST β¦/cab/vote with no open round |
vote recorded | 409 not_awaiting_approval |
GET β¦/cab after a resubmission |
old votes still shown | votes of the current round only (F2) |
GET /changes/{id} |
adds approval {round, route, state, is_independent, current_version} and status.stage |
|
GET /change-statuses |
adds stage |
Both fixes ship with a line in the release notes. The parity harness (Service Layer #8) must show these as the only differences.
Changes in Enforce mode
| Call | Result |
|---|---|
PATCH / POST into an approved / implementing / done status without a current approval |
409 approval_required |
PATCH of CAB / approver fields outside Draft, or POST β¦/cab |
409 approval_locked |
PATCH of a material field on a submitted or approved change |
200; the response shows the new round and the awaiting status |
| Decision by one of the change's own people | 403 not_independent |
Vote or decision whose approval_id / content_hash is not current |
409 approval_stale, with the current approval in the body |
Vote or decision without approval_id / content_hash |
400 missing_field |
DELETE /changes/{id} on a submitted change |
409 change_has_approval_history (also in Record mode with deletion Not allowed) |
New
| Endpoint | Permission | |
|---|---|---|
GET /changes/{id}/approvals |
change_approvals.read |
every round, its snapshot and its votes |
POST /changes/{id}/approval/decide |
change_approvals.decide |
{"decision": "approve"|"reject", "note": "β¦", "approval_id": 12, "content_hash": "β¦"} - the named approver only |
POST /changes/{id}/cancel |
changes.write |
{"reason": "β¦"} - to the first stopped status; an open round β withdrawn |
GET /change-templates |
change_templates.read |
the approved templates the key's analyst may use, with scope and current version |
POST /changes with template_version_id |
changes.write |
raise from a template; pre-authorisation is still decided at submission, as in the UI |
POST /changes/{id}/cab/vote gains the same optional approval_id / content_hash (required in Enforce mode). Templates are written and approved in the UI only for now: approving a recipe is a deliberate act by a person, and a key has no reason to do it.
New permissions start unticked on every existing key, as always.
14. Upgrade, step by step
What Database Verify does on the first run after the upgrade:
- Adds
change_statuses.stageand classifies seeded names (Β§3). Unrecognised names stay NULL. - Adds
change_types.approval_route(review|emergency), defaultreview, for every type. Addschanges.template_version_id, the newchange_templatescolumns andchange_template_versions, with the template switch Off. Nothing becomes pre-authorised by itself, even a type called Standard: only an approved template can pre-authorise, and none exists until somebody writes one and somebody else approves it. - Creates
change_approvals,change_approval_votes,changes.approval_id. - Writes the legacy rounds (Β§8) - one per change in an approved-or-later stage, one open round per change awaiting approval, with existing votes copied.
- Leaves the mode on Record.
The visible result on the day: Watchtower stops counting hand-approved changes as awaiting (F1), and changes stuck by an old Reject become votable again (F2). Nothing is refused.
Switching to Enforce is refused, with the reason, while any active status has no stage. The Approvals tab lists the statuses in the way, with a link to each. It also shows, before the switch, how many changes are currently in an approved-or-later stage with only a legacy round, so the administrator knows those were approved before records were kept.
15. The files
Colour key: ποΈ schema Β· βοΈ engine Β· π API Β· π₯οΈ UI Β· π i18n Β· π docs Β· π§ͺ test
| π¨ | File | What changes |
|---|---|---|
| ποΈ | includes/db_verify_schema.php Β· database/freeitsm.sql |
change_statuses.stage, change_types.approval_route, changes.approval_id, changes.template_version_id, change_approval_votes.was_current, the new change_templates columns, the three new tables; change_approvals FK as RESTRICT; the one-time classification + legacy rounds |
| βοΈ | includes/changes/templates.php (new) |
changeTemplatePreAuthorises(), changeTemplateFixedHash(), version create / approve / retire, who may use it |
| π | api/change-management/get_templates.php Β· api/change-management/save_template.php Β· api/change-management/approve_template_version.php Β· api/change-management/retire_template.php (new) |
the Templates tab, behind Cap::CHANGES_TEMPLATES / Cap::CHANGES_TEMPLATES_APPROVE |
| π | api/change-management/delete.php Β· api/change-management/cancel.php (new) |
the 409 when deletion is not allowed; Cancel |
| βοΈ | includes/changes/approvals.php (new) |
changeApprovalRoute(), changeApprovalGuardMove(), changeApprovalOpenRound(), changeApprovalSnapshot(), changeApprovalIsIndependent(), changeStatusForStage(), changeApprovalSettings() |
| βοΈ | includes/services/changes.php |
every write in a transaction with lockChange(); the guard on every status move; rounds opened, superseded, withdrawn; voteCab() counts per round; new decideApproval(); name literals gone |
| βοΈ | workflow/includes/engine.php |
four new triggers and their condition fields |
| βοΈ | includes/watchtower_queries.php |
stage instead of is_default; the emergency count |
| π | api/change-management/save.php Β· api/change-management/update_field.php Β· api/change-management/save_cab_members.php Β· api/change-management/submit_cab_vote.php |
unchanged adapters; they surface the new 409 messages |
| π | api/change-management/decide_approval.php Β· api/change-management/get_approval_rounds.php (new) |
the approver's decision; the round history |
| π | api/change-management/get.php Β· api/change-management/get_approvals.php |
allowed_statuses, approval, status_stage; stage-based filters |
| π | api/change-management/save_change_status.php Β· api/change-management/delete_change_status.php Β· api/change-management/save_change_type.php Β· api/change-management/save_approval_settings.php (new) |
stage and route; the last-status-in-a-stage rule; the new tab, behind Cap::CHANGES_APPROVALS |
| π | api/v1/resources/changes.php Β· api/v1/lib/routes.php Β· api/v1/lib/permissions.php Β· api/v1/lib/openapi.php |
the two new endpoints and change_approvals permissions; approval and stage in the serialisers |
| π₯οΈ | assets/js/change-management.js Β· assets/css/change-management.css |
the Approval panel, Approve/Reject for the approver, filtered status choices, confirm before a material edit |
| π₯οΈ | change-management/approvals.php Β· change-management/settings/index.php Β· change-management/settings/manifest.php |
new filters; Stage and route columns; the Approvals tab |
| βοΈ | includes/capabilities.php |
Cap::CHANGES_APPROVALS, Cap::CHANGES_CHOOSE_APPROVERS |
| π₯οΈ | change-management/help.php |
rewritten sections (Β§12) |
| π | lang/en/change-management.php (+ the locales) |
new strings |
| π | wiki: Change Management, REST API: Changes, Webhooks, this page | |
| π§ͺ | tests/change-approvals.php (new) |
see Β§16 |
What does not change: change_cab_members' shape, the CAB arithmetic, the change list and calendar, and the table view's inline edit (status was never on it).
16. How it will be tested
Against the dev database, which is real data: every test creates a ZZ- change and deletes it, rounds and revisions with it, in a finally.
| Test | Proves |
|---|---|
| Enforce on: try each status-changing path in Β§1 into Approved, In Progress, Completed | all refused, and a negative control: switch to Record and the same calls succeed |
| CAB all with three required members, two concurrent final votes | one transition, one change.approved, two votes recorded |
| Required Reject, resubmit, then approve | approves (F2 regression test) |
| Edit the test plan after approval, Enforce | round superseded, status awaiting, new round's snapshot has the new revision id |
| Same, Record | approval kept, marked earlier version, Watchtower unchanged |
| Requester votes on their own change, Enforce | recorded with is_counted = 0; threshold not met |
| Rename Approved to Authorised | CAB auto-approval still lands on it (F3) |
| Change from an approved template, untouched, switch On | round pre_authorised, snapshot names the template version |
| Same, rollback plan altered before submission | normal route, with the reason shown; negative control: switch Off β normal route even untouched |
| Template version superseded or retired between raising and submitting; submitter not in Who may use it | normal route in every case |
| Author approves their own template version | refused; an administrator likewise |
| Two tabs: vote in tab A after a material edit in tab B, Enforce | 409 approval_stale, nothing recorded; vote again after reload β counted, with the browser's hash stored |
| Same, Record + Record it and mark it | vote recorded, was_current = 0; negative control: a title-only edit in tab B β was_current = 1 |
| Delete a submitted change, Enforce | 409 change_has_approval_history; Cancel β stopped status, round withdrawn, rounds/votes/revisions all still there |
| Delete a never-submitted draft, Enforce; delete a submitted change, Record + Allowed | both deleted, no FK error (the RESTRICT is handled) |
| Administrator without override | refused like anybody else |
| Parity harness, Record mode | only the two documented differences |
Every HTTP check reads the response body, not only the status: a PHP fatal is served as a 200.