Repository navigation
Change Risk Policy Developer Guide
How FreeITSM lets an organisation set its own Change risk scale - levels, criteria, results and the matrix between them - without ever changing how an existing change reads. Built for #246 (changelog #2736).
For what it does from a user's point of view, see Change risk policies. The agreed design, with the decisions and why, is Change risk policies - design. How the rest of a change works is in Change Management - Developer Guide.
A rating means nothing without its scale, so a change carries its scale. Every save of a policy is an immutable version; changes.risk_policy_version_id points at the version a change was rated under, and every screen, export and API response draws the change on that version - never on the one in use now. The built-in 5 Γ 5 is not stored at all: it lives in code, and NULL means it. So an install that never opens the new tab is byte-for-byte what it was, and no row needed migrating.
| File | Job |
|---|---|
includes/changes/risk_policy.php |
The model. ChangeRiskPolicy: the built-in, lookup by company, validation, assessment, versioned saves, history, the page bootstrap |
includes/services/changes.php |
applyCreate() and updateChange() rate through the policy; the re-assessment rule |
includes/changes/approvals.php |
The approval fingerprint counts the scale |
includes/changes/templates.php |
A template remembers its scale; riskPolicyMatches(); check()
|
api/change-management/get_risk_policy.php, save_risk_policy.php
|
The settings tab's two endpoints, behind Cap::CHANGES_RISK_POLICY
|
api/change-management/save_template.php |
Template ratings checked against the policy, scale recorded |
api/change-management/list.php |
Returns risk_policy_version_id for the list and table |
assets/js/change-risk.js |
The screen side. window.ChangeRisk: labels, criteria, results, colours, selects, the guide, the matrix |
assets/js/change-management.js |
The editor: setEditorRiskScale(), reassessRisk(), updateRiskScore()
|
change-management/settings/index.php |
The Risk policy tab, and the template editor's ratings |
change-management/evidence.php, includes/report_packs/blocks_changes.php
|
Evidence text and report colours from the policy |
api/v1/resources/changes.php |
risk.policy on every change; GET /change-risk-policy
|
tests/change-risk-policy.php |
46 checks - see Testing it |
CREATE TABLE IF NOT EXISTS `change_risk_policies` (
`id` INT NOT NULL AUTO_INCREMENT,
`tenant_id` INT NULL,
`current_version_id` INT NULL,
`updated_by_id` INT NULL,
`updated_datetime` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `ix_crp_tenant` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;-
One row per install today,
tenant_id NULL. A company with its own policy later is another row with itstenant_id. -
current_version_id NULL= the built-in. Use built-in sets it back toNULL; the versions stay. -
A plain key on
tenant_id, not a unique one. MySQL treats everyNULLas distinct in a unique index, soUNIQUE (tenant_id)would not stop two install-wide rows. Insteadsave()finds-or-creates the row inside aGET_LOCK(below).
CREATE TABLE IF NOT EXISTS `change_risk_policy_versions` (
`id` INT NOT NULL AUTO_INCREMENT,
`policy_id` INT NOT NULL,
`version_no` INT NOT NULL,
`content` LONGTEXT NOT NULL,
`content_hash` CHAR(64) NOT NULL,
`created_by_id` INT NULL,
`created_datetime` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_crpv_policy_version` (`policy_id`, `version_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;-
Never updated. There is no code path that writes to a version after its
INSERT. That is what makes "a change keeps its scale" true. -
version_nocounts per policy; the unique key makes a race between two saves fail rather than produce two "version 3"s.
Two columns on existing tables, both NULL = built-in:
`risk_policy_version_id` INT NULL, -- #246: NULL = the built-in 5 x 5on changes, and the same on change_templates. changes.risk_level widened from VARCHAR(20) to VARCHAR(50), because a policy's words can be longer than Very High. CREATE TABLE IF NOT EXISTS cannot widen an existing column, so Database Verification does it with the same probe-then-MODIFY shape as its other upgrades:
if ($rlLen !== false && (int)$rlLen < 50) {
$conn->exec("ALTER TABLE `changes` MODIFY `risk_level` VARCHAR(50) NULL");Widening a VARCHAR cannot invalidate a row, so it is safe on a live table.
{
"name": "Information security policy",
"likelihood": [ { "label": "gering", "criteria": "..." }, ... ],
"impact": [ { "label": "gering", "criteria": "..." }, ... ],
"results": [ { "label": "gering", "colour": "#16a34a" }, ... ],
"matrix": [ [0, 0, 1, 2], [0, 1, 1, 2], ... ]
}- Levels and results are lowest first. A rating is the 1-based position: likelihood
3islikelihood[2]. -
matrix[l - 1][i - 1]is an index intoresults. Rows are likelihood, columns impact. -
results[n].labelis whatchanges.risk_levelstores.
public static function builtin(): array
{
$band = static fn(int $s): int => $s <= 4 ? 0 : ($s <= 9 ? 1 : ($s <= 15 ? 2 : ($s <= 20 ? 3 : 4)));
$matrix = [];
for ($l = 1; $l <= 5; $l++) {
for ($i = 1; $i <= 5; $i++) $matrix[$l - 1][$i - 1] = $band($l * $i);
}The old bands (β€4 Low, β€9 Medium, β€15 High, β€20 Very High, else Critical) written out once as a 25-cell matrix. From here on the built-in is just another policy: the same assess() reads it. The test proves all 25 cells give exactly what riskLevel() used to.
Its levels and results also carry translation keys, which a saved policy never has:
$out[] = ['label' => ($n + 1) . ' - ' . $w, 'criteria' => '', 'key' => 'rl_' . ($n + 1),
'criteria_key' => 'risk_help_' . ($axis === 'likelihood' ? 'l' : 'i') . ($n + 1)];So the server stores and returns the English words, as before, while the screens show change-management.editor.rl_3 (3 - Medium) and the #231 example criteria in the reader's language. A saved policy's words are the organisation's and are shown as typed.
public static function current(PDO $conn, ?int $tenantId): array
{
if (!self::ready($conn)) return ['version_id' => null, 'version_no' => null, 'content' => self::builtin()];
try {
$s = $conn->prepare(
"SELECT p.current_version_id, v.version_no
FROM change_risk_policies p
LEFT JOIN change_risk_policy_versions v ON v.id = p.current_version_id
WHERE p.tenant_id <=> ? OR p.tenant_id IS NULL
ORDER BY p.tenant_id IS NULL, p.id
LIMIT 1"
);Reading the SQL:
-
p.tenant_id <=> ?is MySQL's null-safe equals:NULL <=> NULLis true, whereNULL = NULLis not. Passingnull(templates, the settings tab) matches the install row. -
OR p.tenant_id IS NULLbrings in the install's row as the fallback. -
ORDER BY p.tenant_id IS NULLsorts0(a company's own row) before1(the install's), so a company's policy wins when it has one. -
LEFT JOINbecausecurrent_version_idmay beNULL- the built-in - which still needs the row.
Every caller passes the change's company, which is why per-company policies later are a settings screen and not a rework: applyCreate() passes $tenantId, updateChange() passes $current['tenant_id']. The test inserts a company row and proves the company gets its own while another company and the install still get the install's.
public static function ready(PDO $conn): bool
{
if (self::$ready !== null) return self::$ready;
try {
$conn->query("SELECT risk_policy_version_id FROM changes LIMIT 0");An upgraded install that has not run Database Verification has none of the new columns. ready() probes once per request; until it is true, current() returns the built-in, updateChange() never writes risk_policy_version_id, list.php selects NULL AS risk_policy_version_id, and the settings tab says to run Verification. Changes keep saving exactly as before.
public static function rating($value, string $field, array $content, string $axis): ?int
{
if ($value === null || $value === '') return null;
$n = self::levels($content, $axis);
if (!is_numeric($value) || (int)$value != $value || (int)$value < 1 || (int)$value > $n) {
throw new ServiceError('validation', 'invalid_field', "'{$field}' must be between 1 and {$n}.");
}
return (int)$value;
}The range comes from the policy, so on the built-in the message is word for word the old one (between 1 and 5). (int)$value != $value refuses 2.5, which the old (int) cast silently truncated.
public static function assess(array $content, ?int $likelihood, ?int $impact): array
{
if ($likelihood === null || $impact === null) return [null, null];
$idx = $content['matrix'][$likelihood - 1][$impact - 1] ?? null;
$label = $idx !== null ? ($content['results'][$idx]['label'] ?? null) : null;
return [$likelihood * $impact, $label];
}TRAP: the score is still likelihood Γ impact - the API returns it, reports sort by it, the matrix cells show it - but it does not decide the result. The matrix does. On the test's 4 Γ 4, 1 Γ 4 scores 4 and is hoch, where banding a 4 would give the lowest result. Never band the score.
In applyCreate(), the policy is fetched first, because a template's values are merged before the ratings are read:
// The risk policy new assessments in this company use (#246).
$policy = ChangeRiskPolicy::current($conn, $tenantId); $ratingsFit = ChangeTemplates::riskPolicyMatches($templateVersion['content'], $policy['version_id']);
foreach ($templateVersion['content']['values'] ?? [] as $col => $val) {
if (!$ratingsFit && in_array($col, ['risk_likelihood', 'risk_impact_score'], true)) continue;Then:
$riskLikelihood = ChangeRiskPolicy::rating($in['risk_likelihood'] ?? null, 'risk_likelihood', $policy['content'], 'likelihood');
$riskImpact = ChangeRiskPolicy::rating($in['risk_impact_score'] ?? null, 'risk_impact_score', $policy['content'], 'impact');
[$riskScore, $riskLevel] = ChangeRiskPolicy::assess($policy['content'], $riskLikelihood, $riskImpact);and after the INSERT, the version - only when something was rated, and never for the built-in:
if ($policy['version_id'] !== null && ($riskLikelihood !== null || $riskImpact !== null)) {
$conn->prepare("UPDATE changes SET risk_policy_version_id = ? WHERE id = ?")->execute([$policy['version_id'], $changeId]);
}An unrated change records no version: its first rating, whenever that is, uses the policy in use then.
This is the part to understand before changing anything. The editor's full-form save sends every field, the ratings included, so "a rating was sent" is not the same as "the analyst re-rated it". For a change on an older version, sending its own ratings back must change nothing; sending different ones moves it to the current policy.
$asSent = static function ($v, ?int $old): bool {
return ($v === null || $v === '') ? $old === null : (is_numeric($v) && (int)$v === $old);
};
$same = (!array_key_exists('risk_likelihood', $in) || $asSent($in['risk_likelihood'], $oldLikelihood))
&& (!array_key_exists('risk_impact_score', $in) || $asSent($in['risk_impact_score'], $oldImpact));
$newVersion = $oldVersion;
if ($rated && $oldVersion !== $policy['version_id']) {
if ($same && empty($in['risk_reassess'])) {
$riskTouched = false; // untouched: it stays on the scale it was rated on
} else {
if (!array_key_exists('risk_likelihood', $in) || !array_key_exists('risk_impact_score', $in)) {
throw new ServiceError('validation', 'invalid_field', t('change-management.risk_policy.err_reassess_both'));
}
$newVersion = $policy['version_id'];
}
} elseif (!$rated) {
$newVersion = $policy['version_id']; // a first rating uses the policy in use now
}| The change | What is sent | Result |
|---|---|---|
| On the current version | anything | Validated on the current scale, as before #246 |
| Not rated yet | a rating | Rated on the current policy, version recorded |
| On an older version | its own ratings back (every full-form save) | Untouched - stays on its scale |
| On an older version | different ratings, both | Re-assessed onto the current policy |
| On an older version | different rating, only one | Refused: one new number beside one old one means nothing |
| On an older version |
risk_reassess: true and both |
Re-assessed even with the same numbers - the editor's Re-assess button sends this |
$asSent compares by value, so "5" from a form and 5 from JSON are the same, and '' means cleared.
Then the ratings are judged on the scale the change is going to be on:
$scale = ChangeRiskPolicy::content($conn, $newVersion);and a move is written and audited:
if ($newVersion !== $oldVersion && ChangeRiskPolicy::ready($conn)) {
$updates[] = 'risk_policy_version_id = ?';
$args[] = $newVersion;
$queueAudit('Risk Policy', ChangeRiskPolicy::describeVersion($conn, $oldVersion), ChangeRiskPolicy::describeVersion($conn, $newVersion));
}The audit row reads Risk Policy: Built-in 5 x 5 β Information security policy (version 1), so the history says why the result changed.
One more condition changed: the score and level used to be rewritten only when a number changed. A re-assessment can keep both numbers and still change the result, because the matrix moved:
if ($newLikelihood !== $oldLikelihood || $newImpact !== $oldImpact || $newVersion !== $oldVersion) {When the risk ratings are material (Settings β Approvals, on by default), a change approved at 3 of 5 and re-assessed to 3 of 4 must need a fresh approval, although the numbers did not change. So the fingerprint includes the version:
if ((in_array('risk_likelihood', $material, true) || in_array('risk_impact_score', $material, true))
&& ($row['risk_policy_version_id'] ?? null) !== null) {
$out['risk_policy_version_id'] = (string)$row['risk_policy_version_id'];
}TRAP: only when the version is not
NULL. Adding the key for built-in changes too would change the hash of every approval round taken before #246, and every approved change would suddenly read "approved an earlier version". With this guard, every existing fingerprint is unchanged - the test computes one before the 4 Γ 4 is introduced and compares after.
The extra key is not one of CHANGE_MATERIAL_CHOICES, so it does not appear as a tick in Settings, and changeApprovalRound()'s "what changed" list (which only names long fields with revisions) ignores it.
A template's working draft records the scale of its ratings (change_templates.risk_policy_version_id), and a submitted version freezes it into its content:
if (!empty($t['risk_policy_version_id'])) $content['risk_policy_version_id'] = (int)$t['risk_policy_version_id'];Only when it is a saved policy, so a template rated on the built-in freezes exactly the content it always did. Versions frozen before #246 have no key - the built-in.
public static function riskPolicyMatches(array $content, ?int $policyVersionId): bool
{
$own = isset($content['risk_policy_version_id']) ? (int)$content['risk_policy_version_id'] : null;
return $own === $policyVersionId;
}Two places use it:
-
applyCreate()(above) does not copy the ratings onto a change on another scale. -
check()- the pre-authorisation test - refuses when the change's numbers match the template's but its scale does not:
if ($riskFixed && !self::riskPolicyMatches($v['content'], $rowVersion)) {
return ['ok' => false, 'reason' => 'changed', 'version' => $v, 'changed_fields' => $riskFixed];
}Without that, an analyst who typed the template's 5 and 4 again on a four-level scale (4 only, so say 4 and 4) would get a change "matching the recipe" whose risk nobody approved.
save_template.php applies the same untouched-stays-put rule as updateChange(): unchanged ratings keep their version; new ones are validated on the install's policy (current($conn, null) - templates are install-wide) and record it.
public static function save(PDO $conn, array $in, int $actorId, ?int $tenantId = null): int
{
$content = self::normalise($in);
$json = json_encode($content, JSON_UNESCAPED_UNICODE);
$lock = self::lockName($conn);
$conn->prepare("SELECT GET_LOCK(?, 10)")->execute([$lock]);-
normalise()refuses rather than repairs. 2-10 levels per axis, 2-8 results, a label on every one, no two the same (case-insensitively), criteria up to 1,000 characters, a colour matching^#[0-9a-f]{6}$(so nothing but a colour can reach astyleattribute), and every matrix cell set to a valid result index. It rebuilds the content from those fields only, so nothing else sent is ever stored. -
The lock covers find-or-create of the policy row and the next
version_no. Its name includes the database, becauseGET_LOCKnames are per MySQL server and two installs on one server each have a policy 1 - the same TRAP asdsyncLock(). -
The
INSERTand theUPDATEofcurrent_version_idare one transaction, so a policy never points at a version that was rolled back.
The endpoint is api/change-management/save_risk_policy.php: POST only (csrfRequireWriteMethod()), module write access, and Cap::CHANGES_RISK_POLICY, which the manifest marks sensitive - it decides how risky every new change reads.
Every page that draws a risk gets every version with the page:
$data = ['policies' => (object)self::all($conn), 'current' => $cur['version_id'] ?? 0,
'current_no' => $cur['version_no']];
return '<script>window.CM_RISK = ' . json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE) . ';</script>';-
Version
0is the built-in. Versions are few and small, so sending them all is simpler than fetching per change, and a list can colour every row on its own version. -
JSON_HEX_TAG: the words are the administrator's, and a label containing</script>would otherwise end the block. -
(object)keeps{"0": ...}an object in JSON even when only the built-in exists.
The change page, the table and the settings page call ChangeRiskPolicy::jsBootstrap() and then load change-risk.js.
function resultFor(p, l, i) {
const row = (p.matrix || [])[l - 1];
const idx = row ? row[i - 1] : undefined;
return idx === undefined ? null : (p.results || [])[idx] || null;
}The browser's copy of the bands, riskLevelForScore(), is gone. There used to be two copies of the banding kept in step by hand (PHP and JS); now both sides read the same matrix from the same content.
function paint(r, solid) {
if (!r) return { cls: '', style: '' };
if (r.key) return { cls: 'risk-' + r.key.replace(/_/g, '-'), style: '' };
const c = /^#[0-9a-f]{6}$/i.test(r.colour || '') ? r.colour : '#94a3b8';
return { cls: 'risk-custom', style: solid ? `background:${c};color:#fff;` : `background:${c}22;color:${c};border:1px solid ${c}66;` };
}A built-in result has a key, so it keeps the old CSS classes (risk-very-high) and looks exactly as before. A policy's result is coloured inline from its own colour - 22 and 66 are 8-digit hex alpha, a 13% tint and a 40% border - after checking the value is a colour again, although the server already did.
function fillSelects(versionId, likelihoodSel, impactSel) {
const p = policy(versionId);The two <select>s in change-management/index.php now hold only Not assessed; the options come from the version being rated on. The #231 guide's table body (#riskGuideRows) is drawn from the same version by guideRows(): the built-in's translated example criteria, or the policy's own label and criteria for each level. For a saved policy the worked example (built-in numbers) is hidden and "these are examples, not a policy" becomes "This is your organisation's risk policy: {name}".
change-management.js keeps one piece of state:
let editorRisk = { version: 0, reassess: false };-
New change:
setEditorRiskScale(ChangeRisk.currentId(), false). - Edit: the change's own version if it is rated, otherwise the current one:
setEditorRiskScale(rated ? (c.risk_policy_version_id || 0) : ChangeRisk.currentId(), false);- When that is not the current version,
#riskOlderPolicyshows "Rated under ... Re-assess to rate it on the policy in use." -
Re-assess empties both ratings and calls
setEditorRiskScale(ChangeRisk.currentId(), true); the save then sendsrisk_reassess: true, which is how the server tells a deliberate re-rating to the same numbers from an untouched save. - A template applied in the editor copies its ratings only when
tpl.risk_policy_version_idmatcheseditorRisk.version, and otherwise says why - the same rule the server applies.
updateRiskScore() reads ChangeRisk.resultFor() on editorRisk.version, so the result beside the fields is always the matrix's.
ChangeRisk.matrix(c) replaces the hard-coded 5 Γ 5 renderRiskMatrix(). It draws L rows and I columns from the change's version, highest likelihood at the top, and sizes the grid inline because the CSS fixed it at five:
<div class="risk-matrix-grid" style="grid-template-columns: 24px repeat(${I}, ${Math.max(L, I) > 6 ? 32 : 44}px); grid-template-rows: repeat(${L}, ${Math.max(L, I) > 6 ? 32 : 44}px) 20px;">Cells shrink from 44px to 32px past six levels, so a 10 Γ 10 still fits a phone.
| Where | How |
|---|---|
| Evidence export | Ratings by name and position (gering (1 of 4)), and Risk policy: ... from describeVersion()
|
| Report Packs | The by risk dimension no longer has a SQL CASE of five colours; each group's colour comes from ChangeRiskPolicy::colourFor(), which looks the word up in its own version, then any version |
| Record preview | Unchanged - it shows the stored word |
| Workflows |
change.risk is still the stored word, so conditions keep working; renaming a result in a new version needs workflow conditions updated, and the settings tab says so |
| REST API |
rating() gives the policy's range in the 422; every change's risk gains policy (apiChangeRiskPolicyRef()); GET /change-risk-policy returns the current policy in full. The OpenAPI schemas were edited by hand (never run openapi_fix.php in place) and openapi_check.php passes |
| Feature Bingo |
changes.risk_policy lights when a version has been saved |
| Analyst lifecycle |
change_risk_policies.updated_by_id and change_risk_policy_versions.created_by_id are in analystReferenceRegistry() as history |
-
Never band the score. The result is
matrix[l - 1][i - 1]. Anything new that needs "the risk" readsrisk_level, or callsassess(). -
Never draw a change on the current policy. Use
ChangeRiskPolicy::content($conn, $row['risk_policy_version_id'])(PHP) orChangeRisk.policy(c.risk_policy_version_id)(JS). The current policy is only for new ratings. - Never edit a version. Fix a mistake by saving the next one.
- A full-form save is not a re-assessment. Any new path that writes ratings must keep the untouched-stays-put rule, or every edit of an old change would silently re-read its numbers on the new scale.
- Keep the fingerprint guard. Adding the version to built-in changes' fingerprints would mark every existing approval as out of date.
-
Pass the company. Call
current($conn, $tenantId)with the change's company, nevernull, except for install-wide things (templates, the settings tab today).
tests/change-risk-policy.php:
php tests/change-risk-policy.php
FREEITSM_TEST_DATABASE=<a throwaway database> php tests/change-risk-policy.php
Part 1 needs no database (23 checks): the built-in matches the old bands in all 25 cells; a policy's result comes from its matrix (1 Γ 4 = hoch); ranges, fractions and blanks; and every refusal normalise() makes - empty cell, cell past the results, missing row, too few or too many levels, a bad colour or one carrying CSS, duplicate names, a result too long for risk_level, no name - each paired with the valid policy being accepted.
Part 2 (23 checks) changes the install's policy while it runs, so it refuses anything but the database named in FREEITSM_TEST_DATABASE. Through ChangesService, on a 4 Γ 4 that is deliberately not multiply-and-band: a built-in change keeps 5/5/25/Critical and its fingerprint after the 4 Γ 4 arrives; a new change gets hoch from the matrix; a 5 is refused; an unchanged save leaves an old change alone; one rating is refused; two re-assess it with an audit row; risk_reassess with the same numbers changes the fingerprint; a built-in template is not copied and not pre-authorised on the 4 Γ 4 (with the control that it still is on its own scale); a company's own policy wins; Use built-in leaves rated changes on their version. Its finally removes its changes, template and versions and puts the policy row back.
It was also checked in a browser on the test install, through the real pages - the change page and matrix on both scales, Re-assess, the table, the settings tab (including a refused save with an empty cell and a real save making version 2) and the template editor - and through the REST API with a real key.
- Change risk policies - the user page
- Change risk policies - design - #246, the decisions
- Change Management - Developer Guide
- Approvals and evidence - 1. Change approvals - the fingerprint
- REST API: Changes
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- 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: Files
- β³ π 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
- β³ π Switching modules off
- β³ π οΈ Module availability β Developer Guide
- β³ π Module availability β design (#217)
- 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
- β³ π₯ Importing people from a directory
- β³ π₯ Importing people from Microsoft Entra ID
- β³ π³ OpenLDAP & Samba AD (Docker) setup
- β³ π οΈ LDAP β Developer Guide
- β³ π οΈ Directory sync β Developer Guide
- β³ π§© Extending directory sync
- β³ π οΈ Entra directory sync β Developer Guide
- β³ π₯ Directory sync β design
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π οΈ Microsoft Entra ID import - review (#235)
- β³ π οΈ Assets from their Intune primary user - review (#236)
- β³ π οΈ Organisation chart - review (#242)
- β³ π οΈ Empty Procedures fold - review (#247)
- β³ π« 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: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π οΈ Inbox.js map β Developer Guide
- β³ π οΈ Floating windows & the file viewer β Developer Guide
- β³ π οΈ Tickets settings β Developer Guide
- β³ π Simpler ticket and task forms - design (#218)
- β³ ποΈ The ticket workspace
- β³ π The ticket table
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π§Ύ A type for every new ticket
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
- β³ 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
- β³ π Case reports - a ticket and its work as one PDF
- β³ π Scheduled work in your own calendar
- Problem Management
-
Tasks
- β³ π οΈ Task board and subtasks β Developer Guide
- β³ π§βπ€βπ§ People on a task ("Involved")
- β³ π Repeating tasks
- β³ π’ Moving a task between companies
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π§© Asset type layouts
- β³ π Inventory freshness
- β³ π Linked configuration item
- β³ π Connected assets
- β³ π Asset review
-
β³ π·οΈ QR asset labels
- β³ π οΈ QR asset labels β Developer Guide
- β³ π Asset label designer - design (#229)
- β³ π· Asset scanning β Developer Guide
- β³ π οΈ Asset tag numbering β Developer Guide
- β³ π οΈ Asset reconciliation β Developer Guide
- β³ π οΈ Reconciliation, tags + labels - review (#164)
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ πͺ Hyper-V and Failover Clusters
- β³ π 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
- Starters & leavers
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- π‘ Zabbix (monitoring)
- π¬ Microsoft Teams alerts
- π 3CX (phone system)
-
System
- β³ π οΈ Preferences β Developer Guide
- β³ π Finding people in the Analysts list
- β³ πͺ When an analyst leaves
- β³ π οΈ System β Branding β Developer Guide
- β³ π©Ί Page health (D019) β Developer Guide
- β³ β Feature Bingo
- β³ π·οΈ Cost centres
- β³ π· Photo Album
- β³ Demo Data
- β³ π οΈ Demo Data β Developer Guide
-
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
- β³ π A directory account could be matched to someone else
- β³ π« #244 Reply went to the assigned analyst
- β³ π A lookup answer could name any record
- β³ π« #227 A mailbox could not be deleted
- β³ π« #226 A picture on a note could not be closed
- β³ π» #222 The Assets page never loaded in 4.3.0
- β³ βοΈ #214 A "contains" condition did not match capitals
- β³ βοΈ #216 A workflow email from a chosen mailbox always failed
- β³ π³ The Docker image was missing PHP extensions
- β³ π A failed DNS lookup read as a missing record
- β³ π A link could change data
- β³ π Access outlived its removal
- β³ π Knowledge articles could run scripts
- β³ π Knowledge writes ignored permissions
- β³ π Company boundaries (Oct 2026)
- β³ π Smaller gaps (Oct 2026)
- β³ π Escaped text could leave its attribute
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ 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