Repository navigation
Checklists Closure Gating Review
The second round of the same exercise as Checklists module β house style: what changed between the feat/template-level-checklist-gating branch as written and what was committed, and the reasoning for each change.
Written mostly for Santhosh Srinivasan (Sandy), who designed and built it, and for anyone else contributing to FreeITSM later.
Source: discussion #138 Β· shipped as changelog #1874β#1881.
For how the module works, see Checklists & SOPs β developer guide.
The design is right, and the argument for it was made before a line was written. Three things are worth saying plainly:
- The problem was found in use, not in theory. "Optional onboarding tips" should never trap an urgent ticket; "offboarding access revocation" should never be waved through. One install-wide switch cannot express both, and no amount of care in choosing its value fixes that. The fix is to move the decision to the person who wrote the procedure, and that is what this does.
-
It went in at the right layer. The gate is resolved in
ChecklistsServiceand enforced fromassertClosureAllowed(), whichTicketsService::updateTicket()already calls before the write. Nothing in the inbox, in bulk actions, in the REST API or in the workflow engine had to learn the new rule, and none of them can get round it. -
It found a bug nobody was looking for.
attach_templatewas writingNOW()where every other timestamp in FreeITSM isUTC_TIMESTAMP()β a leftover of #126 that survived the first review. Fixed in passing, unprompted.
Most of what follows is conformance work. Four items are not, and those come first.
As written, the new "closed with no checklist" note did this:
} else {
$conn->prepare(
"INSERT INTO ticket_notes (ticket_id, analyst_id, note_text, is_internal, created_datetime)
VALUES (?, 1, ?, 1, UTC_TIMESTAMP())"
)->execute([$ticketId, "[Workflow Note]\n" . $note]);
}
} catch (Throwable $e) {}Three problems, compounding:
`analyst_id` INT NOT NULL,
CONSTRAINT `fk_notes_analysts` FOREIGN KEY (`analyst_id`) REFERENCES `analysts` (`id`)-
analyst_id = 1may not exist. On an install where the first analyst has been deleted, the insert raises a foreign-key violation. -
The
catchis empty, so that violation is swallowed. The close succeeds and the audit entry silently is not written. - Where analyst 1 does exist, an automated close is stamped with a real colleague's name. Someone reading the timeline sees a person who did not do it.
This is the module's own lesson pointed at its own audit trail. From round one:
An unenforced gate is worse than no gate β it reports control that is not there.
An audit note that silently is not written is the same failure. A compliance gate whose evidence is missing on exactly the installs where it matters is worse than no gate, because the absence is invisible.
Now, both closure notes go through one private writer:
private static function writeClosureNote(PDO $conn, ActorContext $ctx, int $ticketId, string $note): void
{
try {
if ($ctx->actorId > 0) {
// a person: an internal note on the ticket
} else {
$conn->prepare(
"INSERT INTO ticket_audit (ticket_id, analyst_id, field_name, old_value, new_value, created_datetime)
VALUES (?, NULL, 'Workflow Note', NULL, ?, UTC_TIMESTAMP())"
)->execute([$ticketId, $note]);
}
} catch (Throwable $e) {
error_log('[checklists] could not record closure override for ticket ' . $ticketId . ': ' . $e->getMessage());
}
}ticket_audit.analyst_id is nullable precisely so an action with no human behind it can be recorded honestly, and it is where the workflow engine's own notes already go. The existing recordClosureOverride() did this correctly and carried a comment saying why; the new branch was added beside it rather than through it, so the reasoning was not in the path of the person writing the second one. Both go through the same writer now β that is the actual fix. The comment is only a comment; the shared function is the thing that cannot be walked past.
π If you find yourself writing a second copy of something that has a π΄ comment on it, the comment is in the wrong place. Put the behaviour in one function instead.
// assertClosureAllowed() β decides whether the close is REFUSED
ticketChecklistEmptyClosureMode($conn, $tenantId)
// recordClosureOverride() β decides whether it is RECORDED
ticketChecklistEmptyClosureMode($conn, null) // β install-wideOn a single-company install these are the same value and the bug is invisible. On a multi-company install they are not: one company's setting could refuse the close while the audit decision came from the install default. A rule that is enforced per company and audited install-wide is two rules.
recordClosureOverride() now takes ?int $tenantId and both call sites β TicketsService::updateTicket() and workflow/includes/engine.php β pass the same $closeTenant they already pass to the refusal.
β οΈ nullis a real value here, not "unknown".ticketTenantId()returnsnullfor a ticket that belongs to no company, so$a ?? $bon a tenant id silently falls through to something else. There was one of these in the endpoint too ($ticketTenantId ?? ($tenantId ?? null)); it is now the ticket's tenant, full stop.
scored.forEach(({ template: t }) => {
// ...
typeof t === "function" ? t("tickets.checklists.mandatory_for_closure") : ""t is the template row. It shadows the global t() translation function, so typeof t === "function" is always false, the expression always yields "", and the tooltip always falls back to the English default. The code reads exactly like code that handles internationalisation and cannot ever do it.
This is the third defect in this module of a shape worth naming: it is invisible to a locale completeness score. The key exists in lang/en, every locale can translate it, every percentage reads 100% β and the string never reaches the screen in any language but English.
Now, the way assets/js/calendar.js already does it:
function chkT(key, fallback) {
const v = (typeof window.t === 'function') ? window.t(key) : '';
return (v && v !== key) ? v : fallback;
}window.t cannot be shadowed by a local variable.
The same 400-character icon expression had been pasted into three places β the inline panel, the popup modal and the attach dialogue β two of them inside those shadowed loops. It is one closureLockIcon(effectiveMode, px) function now. Pasting is how one bug became three.
Nothing ever wrote inherit: the editor writes warn or block, and effectiveClosureMode() mapped anything that was not block to warn. So the column had three values, two meanings, and a name that described behaviour it did not have. A later reader would reasonably assume inherit follows the company setting β and write code depending on it.
ENUM('warn','block') NOT NULL DEFAULT 'warn' in both database/freeitsm.sql and includes/db_verify_schema.php.
The schema was declared in both places correctly β that is the rule from round one and it was followed without prompting.
dbVerifyColumnSelfCheck()reports zero drift across the whole schema.
PDO $conn comes first. effectiveClosureMode(string $mode, PDO $conn, ...) β effectiveClosureMode(PDO $conn, string $mode, ...). Every other method in the service takes the connection first; an argument order that is right everywhere except one place is a trap for whoever calls it next.
There is already a helper for that. The branch hand-rolled SELECT tenant_id FROM tickets WHERE id = ? twice. ticketTenantId(PDO $conn, int $ticketId): ?int lives in includes/tenant_settings.php, which the endpoint already required β and it handles the missing-table case the hand-rolled version did not.
One way of calling t(), not three. Within about sixty lines of inbox.js there were three: a local safeT() helper that guessed whether a key was missing by testing v.startsWith('tickets.'), a bare t(...), and t(...) || 'fallback'. The sniff would also discard a legitimate translation that happened to begin "tickets.". The file's own convention is a bare t() β the keys are in lang/en, so they exist.
A dialogue whose Cancel does nothing is not a dialogue. Two of the new showConfirm() calls were awaited and their answer discarded, then the same thing happened either way. showConfirm() has no single-button mode, so rather than fake one both buttons now mean something: the close is refused regardless, and OK is a shortcut to the thing that would unblock it β the attach dialogue, or the checklist itself.
An invalid setting must not quietly loosen a gate. save_checklist_settings.php rejected an unknown mode but silently defaulted an unknown empty_mode to off. That is the wrong direction for a compliance control: it reports success while relaxing the thing somebody tightened. Both refuse now.
Deleted comments. The branch removed the docblock from save_checklist_settings.php explaining why the setting is install-wide and how tenantSetting() will resolve a per-company override later, the note that setting_key is the primary key so the upsert is safe, the one about reading the value back rather than echoing the input, and an inline // or the dropdown shows a status never applied in inbox.js. All restored.
π This is why the branch was applied to the working tree and reworked before committing, rather than merged and repaired afterwards. Repair-after-merge makes a restored comment look like a new addition in the follow-up diff, and anything missed stays deleted with nothing to show it ever existed. Prose disappearing is the hardest thing to see in a diff. Applied as a patch, every deletion has to be justified before it can be committed.
Standardising on Checklist β over a mixture of "SOP checklist", "Attach a procedure" and "SOP step ticked off" β is right, and it went in across the inbox, ticket view, editor, workflow editor, settings, help page and demo data.
One consequence to be aware of, because it is not obvious from the diff:
β οΈ It is an English-only change. The twelve locales at 100% still saySOP-Checkliste,Lista kontrolna SOP,SOP-sjekkliste,SOP-tjekliste. Until a fan-out run over the renamed strings, the product says "Checklist" in English and "SOP checklist" in twelve other languages. The cleanup is half done until then, and that is the half nobody sees when reviewing in English.
The new keys have the same effect in the other direction: about twenty-five additions to lang/en immediately move every 100% locale off 100%.
Also fixed: checklists.help.steps_intro contains intended <em> markup and help.php was passing it through htmlspecialchars(), so the help page literally printed <em>set the account up</em>. Spotted and fixed in the branch. Recorded here because several neighbouring strings in the same file are echoed raw and this one was the odd one out for no reason β the general rule is still to escape, and a string that carries markup should be obvious from its key or its neighbours.
And a translator trap made worse before it was fixed: lang/en/tickets.php carried a π΄ TRANSLATORS: this is a WARNING, not a refusal note, and the new blocked_title / blocked_message β which are refusals β were added directly beneath it. The group is now split into labelled WARNING and REFUSAL blocks. Six dialogue strings that had been filed under settings.checklists moved to checklists, next to the dialogues they belong to.
The company override only ever tightens. block_all overrides every template; there is deliberately no setting that relaxes a Critical checklist to a warning. An administrator who wants that should edit the checklist, where the change is visible to the people following the procedure β rather than have a setting three screens away quietly disarm a gate its author chose.
The "no checklist attached" rule is install-wide, and will probably want a scope. Most desks should leave it off: a password reset needs no checklist, and insisting on one adds friction to exactly the tickets that least deserve it. It went in as offered because as a default-off switch it harms nothing, but the natural shape is per ticket type or per category rather than per install. Worth knowing before anybody builds on it.
On Tasks: the suggestion to badge the Tasks scope as roadmap rather than build it is the right call. Subtasks already carry assignment and due dates; checklists are in-the-moment procedure. Offering both inside Tasks invites teams to use them interchangeably, and the distinction is hard to re-establish once that has happened.
-
Through
TicketsService::updateTicket(), not throughChecklistsServicedirectly β that is the choke point the inbox, bulk actions, the REST API and workflows all share, and testing the rule rather than the path is how the original gate came to be unenforced. - Every refusal paired with a positive control. A Critical checklist refuses; a Standard one with the same outstanding step still closes; a Critical one whose step is ticked closes. A gate that refuses everything looks identical to a gate that works.
-
The workflow fix asserted directly: an automated close writes zero rows to
ticket_notesand one toticket_auditwith a NULL analyst. -
One process per scenario.
tenantSetting()memoises into astatic $cacheβ right for a web request, fatal to a test that changes a setting and reads it back. The first run of this suite reported four false failures for exactly that reason.
π That trap is written down in section 9 of the round-one page, from the first review of this same module, and it was still walked into. Written-down knowledge only helps when somebody re-reads it β which is an argument for tests that fail loudly rather than notes that explain why they failed.
A fifth apparent failure was a LIKE '%steps outstanding%' pattern that could not match a note reading step(s) outstanding. Worth mentioning only because it is the same category: a check that can fail for its own reasons will, and every red needs reading before it is believed.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96