Skip to content

Change Risk Policy Developer Guide

Ed Mozley edited this page Nov 5, 2026 · 1 revision

Change risk policies - 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.


The one idea

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.


The shape of it

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

The data

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 its tenant_id.
  • current_version_id NULL = the built-in. Use built-in sets it back to NULL; the versions stay.
  • A plain key on tenant_id, not a unique one. MySQL treats every NULL as distinct in a unique index, so UNIQUE (tenant_id) would not stop two install-wide rows. Instead save() finds-or-creates the row inside a GET_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_no counts 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 5

on 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.

A version's content

{
  "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 3 is likelihood[2].
  • matrix[l - 1][i - 1] is an index into results. Rows are likelihood, columns impact.
  • results[n].label is what changes.risk_level stores.

The built-in, in code

    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.


Looking a policy up

    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 <=> NULL is true, where NULL = NULL is not. Passing null (templates, the settings tab) matches the install row.
  • OR p.tenant_id IS NULL brings in the install's row as the fallback.
  • ORDER BY p.tenant_id IS NULL sorts 0 (a company's own row) before 1 (the install's), so a company's policy wins when it has one.
  • LEFT JOIN because current_version_id may be NULL - 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.

Before Database Verification

    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.


Rating: validate, then read the matrix

    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.


Creating a change

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.


Updating a change: the re-assessment rule

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) {

Approvals: the scale is material

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.


Templates

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.


Saving a policy

    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 a style attribute), 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, because GET_LOCK names are per MySQL server and two installs on one server each have a policy 1 - the same TRAP as dsyncLock().
  • The INSERT and the UPDATE of current_version_id are 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.


The screen: assets/js/change-risk.js

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 0 is 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.

The result is read from the matrix

    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.

Built-in classes, policy colours

    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.

Selects and the guide

    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}".

The editor

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, #riskOlderPolicy shows "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 sends risk_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_id matches editorRisk.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.

The matrix

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.


Elsewhere

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

Traps

  • Never band the score. The result is matrix[l - 1][i - 1]. Anything new that needs "the risk" reads risk_level, or calls assess().
  • Never draw a change on the current policy. Use ChangeRiskPolicy::content($conn, $row['risk_policy_version_id']) (PHP) or ChangeRisk.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, never null, except for install-wide things (templates, the settings tab today).

Testing it

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.


Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally