Change Management Developer Guide - edmozley/freeitsm GitHub Wiki

Change Management - Developer Guide

How Change Management is built. For what it does and how to use it, start with the user page: Change Management.

In plain words

A change is one row in changes. Every write to it - from the change page, the table view, the right-click menu or the REST API - goes through one class, ChangesService (includes/services/changes.php). That class keeps the audit trail, the full history of the long-text fields, the approval rounds and the workflow events, all inside one transaction per write.

The approval rules (stages, Record / Enforce, rounds, votes, templates) live in three helper files under includes/changes/. They were designed in the three-part Approvals and evidence series; this page summarises them and links there for the detail.

The front end is one big script, assets/js/change-management.js, behind change-management/index.php. The Calendar, Table and Approvals pages are thin pages with their own small scripts.

The files

Area File What it is
⚙️ Service includes/services/changes.php ChangesService - every write rule for a change
⚙️ Approvals includes/changes/approvals.php stages, approval settings, rounds, the guard on status moves
⚙️ Text history includes/changes/revisions.php ChangeRevisions - every saved version of the long-text fields
⚙️ Templates includes/changes/templates.php ChangeTemplates - standard-change templates and their approved versions
🖥️ Page change-management/index.php the list, detail view and editor (one page, three views)
🖥️ Page change-management/new/index.php /change-management/new/ - sets $openCreateOnLoad and includes ../index.php
🖥️ Page change-management/calendar.php + assets/js/change-calendar.js month / week / day calendar
🖥️ Page change-management/table.php + assets/js/change-table.js full-screen grid on the shared data-table engine
🖥️ Page change-management/approvals.php the approvals queue (its script is inline)
🖥️ Page change-management/evidence.php the server-rendered, printable evidence export
🖥️ Page change-management/settings/index.php + manifest.php Settings tabs and the capability each needs
🖥️ Script assets/js/change-management.js the main front end (see below)
🖥️ Script assets/js/change-revisions.js the text-history viewer (get_revisions.php, get_revision.php)
🔌 UI API api/change-management/*.php session endpoints - thin adapters over the service
🔌 REST api/v1/resources/changes.php /api/v1/changes... - the same service, from an API key
📁 Files change-management/attachments/<change id>/ uploaded files; never served directly (.htaccess + web.config deny), only through get_attachment.php

Tables

All in includes/db_verify_schema.php and database/freeitsm.sql.

Table Holds
changes the change: lookups (change_type_id, status_id, priority_id, impact_id, category_id), people (requester_id, assigned_to_id, approver_id, created_by_id), work and outage windows, the six rich-text bodies, risk (risk_likelihood, risk_impact_score, risk_score, risk_level, risk_policy_version_id), PIR fields, cab_required / cab_approval_type, approval_id (the current round), approval_datetime, template_version_id, tenant_id
change_statuses name, colour, stage, is_closed, default / order / active
change_types the same, plus approval_route (review or emergency)
change_priorities, change_impacts, change_categories plain lookups
change_field_sections, change_field_layout the configurable form (which section each fixed field sits in, its order, visible or not)
change_cab_members the CAB roster, and each member's vote in the current round
change_approvals one row per approval round - route, state, snapshot, content_hash, who decided, is_independent, closed_reason
change_approval_votes every vote ever cast - is_counted, content_hash, was_current
change_text_revisions every saved version of a long-text field, and the text of deleted comments
change_audit the audit trail - action_type, field_name, old_value, new_value
change_comments comments on the timeline
change_attachments files, with sha256 and client_reference (REST idempotency)
change_tickets tickets of any type linked to a change (the screen said "incidents" until 4.9.0, #245)
change_templates, change_template_versions, change_template_teams standard-change templates: the working draft, its versions, who may use it
change_relations a change's link to a problem - written from Problem Management, not from this module

change_checklist_items and change_notifications are in the schema but nothing in the module reads or writes them; only the analyst offboarding map (includes/analyst_lifecycle.php) mentions them.

⚠️ TRAP: change_approvals is ON DELETE RESTRICT from changes - a change with rounds is evidence. deleteChange() deletes the rounds itself, deliberately, before the change row. Votes cascade from their round.

The service: ChangesService

Each UI endpoint and each REST handler turns its caller into an ActorContext plus an input array and calls one method. The service validates, writes and returns an id or a small result array, or throws ServiceError; it never sends HTTP. See Service Layer Architecture for the pattern.

Public methods

Method What it does
createChange($conn, $ctx, $tenantId, $in) Inserts a change in the company the adapter chose. Lookups by name or <key>_id, strict (an unknown one is a 422); defaults from lookupDefault(). Computes risk. Writes the "Created as ..." audit row and a first text revision for each long field. With template_version_id it fills in the template's values and may pre-authorise. Created straight into an awaiting stage opens a round; straight into approved records a manual round. Fires change.created.
updateChange($conn, $ctx, $changeId, $in) Partial update - absent keys are left alone, so the table's inline edit (update_field.php) is the same method with one key. Writes one audit row per changed field, a revision per changed long field, recomputes risk, then moves the approval round with the status (see Lifecycle).
cancelChange($conn, $ctx, $changeId, $in) Moves the change to the Stopped-stage status changeCancelTarget() picks, with an optional reason, and withdraws an open round. What a change with approval history gets instead of Delete.
deleteChange($conn, $ctx, $changeId) Refused (409 change_has_approval_history) where the settings keep submitted changes. Otherwise deletes the rounds, attachment rows and the change in one transaction, and the files only after it has committed. Fires change.deleted.
decideApproval($conn, $ctx, $changeId, $in) The named approver's Approve / Reject, on a change with no CAB. Checked against the version on screen and for independence.
overrideApproval($conn, $ctx, $changeId, $in) An administrator approves without the vote, with a written reason. Only when Settings → Approvals allows override; recorded as an override round.
createComment(), deleteComment() A comment is always internal. Deleting copies the text, author and date into change_text_revisions (source deleted) first, then audits the deletion.
saveCab($conn, $ctx, $changeId, $members) Replaces the CAB roster by diff (add / remove / required-optional), one audit row each. Fixed once submitted in Enforce mode.
voteCab($conn, $ctx, $changeId, $in) The acting analyst votes Approve, Reject or Abstain. Needs an open round and a roster place, and refuses a second vote. Writes the member row and a change_approval_votes row, then counts the round.
linkTicket(), unlinkTicket() Link / unlink a ticket (any type) in change_tickets; same company required on a multi-company install; audited.
uploadAttachment($conn, $ctx, $changeId, $file, $reference) Stores the file through uploadStoreFile() (includes/uploads.php), hashes it, and inserts the row - in its own transaction (it throws if one is already open, because a rollback cannot undo a file move). A repeated client_reference with the same name and bytes returns the existing id with replayed: true.
auditAttachment() The audit row for a file added or removed, with the start of its sha256. delete_attachment.php calls it.

Rules every write follows

  • One transaction, one row lock. inTransaction() runs the work (or joins the caller's transaction - analyst offboarding moves many records at once), and lockRow() takes SELECT ... FOR UPDATE on the change. Two last votes landing together used to both tip the threshold; now the second waits.
  • Events after commit. The apply*() methods return closures; afterCommit() fires them. A rolled-back save never sends its email.
  • Company scope in one place. loadJoined() 404s a change outside the actor's company scope, the same as one that does not exist. NULL tenant_id counts as the Default company. See Multi-Tenancy Developer Guide.
  • Read only is refused in the service too. assertCanWrite() refuses an actor whose module level is read, so an API key acting as a read-only analyst cannot write either. It is deliberately not called by voteCab() or decideApproval(): approving is identity, not a write, so a read-only approver can still approve.
  • Risk (since #246). Ratings are validated and assessed by ChangeRiskPolicy (includes/changes/risk_policy.php) on a risk policy: the change's own version, or the one in use for a first rating. The result is read from the policy's matrix - never by banding the score, which is still likelihood x impact. risk_level stores the result's word: on the built-in 5 x 5 the same English word as before (Low ... Critical), which the front end translates for display; on a saved policy the organisation's own word, shown as typed. risk_policy_version_id (NULL = built-in) records the scale. A full-form save that sends a change's own ratings back leaves an older change on its scale; different ratings or risk_reassess move it to the current policy. The whole model is in Change risk policies - Developer Guide.
  • Dates go through parseDate() to UTC Y-m-d H:i:s; an unparseable one is a 400.
  • People must exist and be active (resolveAnalyst()); the audit trail names them, not their ids.

The lifecycle in code

A status's name means nothing to the code - its stage does. CHANGE_STAGES in includes/changes/approvals.php is draft, awaiting, approved, implementing, done, stopped; stageOf() reads it, and a NULL stage means "not classified yet". When the code needs to move a change it asks changeStatusForStage($conn, $stage) for the first active status in that stage - never a name. Why: Part 1 §3.

What applyUpdate() does when the stage changes:

Move What happens to the round
into awaiting changeApprovalGuardSubmit() checks somebody independent can decide it (Enforce); the old round is closed (withdrawn or superseded), CAB votes are cleared, a new round opens. Fires change.submitted or change.resubmitted.
into approved by hand Stamps approval_datetime and records a manual round, so the history never shows a vote that did not happen. Fires change.approved with route manual.
back to draft, or past the decision by hand The round is withdrawn (an emergency round stays open into Implementing and Done). Fires change.approval_withdrawn.
a material edit while a round stands Where an edit withdraws approval (Enforce, or Settings say so): the round is superseded, an approved change goes back to awaiting, and a new round opens. Otherwise nothing is written and the panel says "approved against an earlier version".

The guard. In Enforce mode every path that sets status_id calls changeApprovalGuardMove(?array $round, array $to, array $cfg), which refuses (409 approval_required) a move into Approved, Implementing or Done without an approved round, except an open emergency round going into Implementing or Done. Record mode never refuses. The editor's status dropdown disables what the guard would refuse, from allowed_status_ids (changeApprovalAllowedStatuses(), returned by get.php) - comfort only.

⚠️ The code sketch in Part 1 §10 shows the design-time signature with $conn, $change and $from. The function as built takes (?array $round, array $to, array $cfg), and the row lock is lockRow().

Settings come from changeApprovalSettings(): the system_settings keys in CHANGE_APPROVAL_DEFAULTS (every default is the pre-3.8.0 behaviour), normalised into $cfg. In Enforce mode edit_withdraws, stale_refused and delete_refused are always true.

CAB and approvers

changeApprovalRoute() picks the route for a new round: cab when cab_required, approver when there is a named approver, else manual. routeFor() makes it emergency for a type whose approval_route is emergency. The other routes - override, pre_authorised, legacy - are written by the code that records them.

applyVote() counts only this round's counted votes from change_approval_votes:

  • one counted required Reject → the round is rejected and an awaiting change moves to rejectTarget() (the Settings choice if it is still an active Draft or Stopped status, else the first Draft one);
  • all: every counted required member approves; majority: more than half → approved, and an awaiting change moves to the first approved-stage status;
  • Abstain never moves anything; optional members never count;
  • voteCounts(): in Enforce mode the change's own people (changeApprovalOwnPeople()) are recorded but not counted, unless Settings allows it. In Record mode they count and the round is marked not independent.

A vote or decision carries the round id and content_hash the page was drawn with; changeApprovalCheckVersion() marks it was_current = 0 or refuses it. Full design: Part 1 §5 and §7.

Who may set up the approval. Setting the approver, cab_required, cab_approval_type or the roster needs analystMayAct($conn, $actorId, Cap::CHANGES_CHOOSE_APPROVERS), which only bites when Settings → Approvals restricts it. See Part 3.

Standard changes (templates)

ChangeTemplates (includes/changes/templates.php). The Templates tab edits the working draft in change_templates; submitVersion() freezes it as a change_template_versions row; approveVersion() approves it (superseding the previous approved one; the version's author cannot approve it unless Settings allows approving your own); retire() retires the template. mayUse() reads change_template_teams (no rows = everyone with the module).

In the service, preAuthorise() calls ChangeTemplates::check(): the version must be approved and not retired, templates must be switched on, the person must be allowed to use it, and the sha256 of the template's fixed fields must still match the change (fixedHash()). If so, it records an approved pre_authorised round and moves an awaiting change on to Approved. If not, the change takes the normal route. With templates switched off they still pre-fill a change; they never approve one. Design: Part 1 §8.

Text history and evidence

ChangeRevisions::FIELDS lists the eight long-text fields that keep every version: the six rich-text bodies plus pir_lessons_learned and pir_follow_up. write() stores the whole new value in the same transaction as the save; ensureBaseline() first stores the old value as a baseline ("as found when history began") for a change older than the feature. The audit row is text_edit with Revision #<id>. Design: Part 2.

change-management/evidence.php renders the evidence pack on the server: the change, every round and vote, every text version, the audit trail, comments including deleted ones, and attachments with sha256. Taking it writes an evidence_export audit row. Every stored text is printed through sanitiseUserHtml() or escaped. The share menu's PDF option is a different, client-side export (shareChangePdf() / buildPdfContent() / changePdfDocument()): html2canvas draws the content off screen at the printable width and the picture is cut into A4 pages, each placed in the PDF by FreeITSM's shared PDF writer (FitsmPdf.create(), on PDFKit since #254 - see PDF writer - Developer Guide). Until 4.1.0 html2pdf did this with its own older copy of jsPDF bundled inside it; the three steps then moved into changePdfDocument() so the install had one, current jsPDF, and since #254 there is no jsPDF at all. The text colour is set there too - inherited from a dark theme it came out pale grey on the white page.

The UI endpoints

All need a session and requireModuleAccessJson('changes'). Writes also call requireModuleWriteJson('changes') (Read only access is refused there and in the service). Voting (submit_cab_vote.php) and the approver decision (decide_approval.php) deliberately do not.

Group Endpoints
Read list.php (status and search filters, counts; ?analysts=1 for the people list), get.php (the change plus approval, approval_rounds, approval_rules, template, allowed_status_ids), get_calendar_changes.php, get_approvals.php (filter = all, assigned, requested, cab), get_audit.php, get_comments.php, get_revisions.php, get_revision.php, get_attachment.php, get_field_layout.php, get_usable_templates.php, list_linkable_tickets.php
Write save.php (create or full update - maps the UI's date keys to the service's *_at keys), update_field.php (one field), cancel.php, delete.php, save_comment.php, delete_comment.php, save_cab_members.php, upload_attachment.php, delete_attachment.php, link_ticket.php, unlink_ticket.php, move_to_company.php, send_share_email.php, override_approval.php
Approve submit_cab_vote.php, decide_approval.php
Settings save_/delete_change_status.php, _priority, _type, _impact (each behind its own Cap::CHANGES_*), save_field_layout.php (Cap::CHANGES_FIELDS, against the fixed catalogue in _field_catalogue.php), get_/save_approval_settings.php (Cap::CHANGES_APPROVALS), get_templates.php / save_template.php (Cap::CHANGES_TEMPLATES), template_action.php (action = approve or retire, requireActionJson(Cap::CHANGES_TEMPLATES_APPROVE, $conn))

The file list in Part 1 §15) names approve_template_version.php, retire_template.php and get_approval_rounds.php. As built, the first two are template_action.php, and the rounds come back inside get.php.

The front end

assets/js/change-management.js is split into regions by // ============ Name ============ comments. The ones worth knowing:

Region Main functions
Initialization the DOMContentLoaded handler - loads layout, lookups, analysts and the list; opens ?change_id= (also open, change, id) or create mode for /new/
Form Layout loadFormLayout(), isFieldVisible(), refreshFormLayout() - places the fixed fields into the configured sections
Data Loading loadStatuses(), loadChanges() (list.php), updateCounts()
View Change Detail viewChange() (get.php), renderChangeDetail()
Linked tickets (function names kept from when it said "incidents") renderLinkedIncidents(), linkChangeIncident(), unlinkChangeIncident()
Risk Matrix / PIR renderPirDetail(), updateRiskScore(), updatePirVisibility(), and since #246 setEditorRiskScale() / reassessRisk(). The matrix, badges, level names and colours come from window.ChangeRisk in assets/js/change-risk.js, drawn on the version each change was rated under; the old browser copy of the bands (riskLevelForScore()) and renderRiskMatrix() are gone - both sides read the same matrix. change-table.js names the level through ChangeRisk too. See Change risk policies - Developer Guide. 4.8.0, #231: the rating guide (#riskGuide, opened by the .cm-risk-help-btn buttons) is static markup in index.php, toggled by a small block beside updateRiskScore(); Escape is caught in the capture phase so it closes the guide without closing the editor
Activity Timeline loadActivityTimeline() - fetches get_audit.php and get_comments.php in parallel and merges them newest first; renderActivityTimeline(), postComment()
Create / Edit openCreateChange(), editCurrentChange(), loadChangeTemplatePicker(), applyChangeTemplate(), applyApprovalEditorRules() (disables statuses and locks the approval set-up), savingWithdrawsApproval() (warns before a material edit)
Save / Delete saveChange() (saves, then the CAB roster when CAB is on), deleteCurrentChange(), confirmDelete()
Attachments setupFileUpload(), uploadFiles(), deleteAttachment()
TinyMCE / tabs initEditors(), destroyEditors(), switchTab() - the six rich-text fields share one tabbed widget
CAB editor toggleCabConfig(), addCabMember(), renderCabMemberChips()
CAB Review Panel renderApprovalPanel(), renderCabReviewPanel(), submitCabVote(), submitApproverDecision(), handleStaleApproval(), openOverrideDialog(), cancelCurrentChange()
Share shareChangeLink(), shareChangePdf(), sendShareEmail(), buildPdfContent(), changePdfDocument()
Context menu openChangeContextMenu(), saveChangeFieldFromContext() (status, priority, type, impact through update_field.php), moveChangeToCompanyFromContext()

The table view's inline edit (change-table.js) posts one field to update_field.php; status is not editable there.

Linked tickets (4.9.0, #245)

Changelog #2730, commit 41fa6fd43. Any ticket - an incident, a request, a problem report, whatever types the install has - could always be linked to a change; nothing in the code ever filtered by type. The screen just called them all "incidents". 4.9.0 changed the wording to "tickets" in every locale and shows each linked ticket's type. The table, the API field and the stored audit marker were deliberately left alone, and that is most of what a developer needs to know here.

The table

change_tickets in database/freeitsm.sql:

CREATE TABLE IF NOT EXISTS `change_tickets` (
    `id`               INT NOT NULL AUTO_INCREMENT,
    `change_id`        INT NOT NULL,
    `ticket_id`        INT NOT NULL,
    `created_by_id`    INT NULL,
    `created_datetime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uq_change_ticket` (`change_id`, `ticket_id`),
    KEY `ix_ctickets_ticket` (`ticket_id`),
    CONSTRAINT `fk_ctickets_change` FOREIGN KEY (`change_id`) REFERENCES `changes` (`id`) ON DELETE CASCADE,
    CONSTRAINT `fk_ctickets_ticket` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`id`) ON DELETE CASCADE

A plain many-to-many: a ticket can be linked to several changes and a change to several tickets, once each (uq_change_ticket). There is no type column, and none is needed - the type is the ticket's own ticket_type_id, read through a join when it is shown. The link goes with either side when that side is deleted.

Choosing a ticket: list_linkable_tickets.php

api/change-management/list_linkable_tickets.php?change_id=&q= feeds the Link ticket picker. It builds its WHERE in steps:

// Scope to the change's own company (matches the same-company rule in link_ticket).
$where = " WHERE t.deleted_datetime IS NULL AND t.closed_datetime IS NULL";
$params = [];
if (isMultiTenant($conn)) {
    $ct = $conn->prepare("SELECT tenant_id FROM changes WHERE id = ?");
    $ct->execute([$changeId]);
    $cten = $ct->fetchColumn();
    $eff = ($cten === null || $cten === false) ? getDefaultTenantId($conn) : (int) $cten;
    if ($eff === getDefaultTenantId($conn)) {
        $where .= " AND (t.tenant_id = ? OR t.tenant_id IS NULL)";
        $params[] = $eff;
    } else {
        $where .= " AND t.tenant_id = ?";
        $params[] = $eff;
    }
}

// Exclude already-linked.
$where .= " AND t.id NOT IN (SELECT ticket_id FROM change_tickets WHERE change_id = ?)";
$params[] = $changeId;
  • Open, not trashed. deleted_datetime IS NULL drops the ticket trash; closed_datetime IS NULL drops closed tickets. A status that is not is_closed (such as a Resolved status set up that way) is still offered.
  • The change's company, not the analyst's. On a multi-company install the picker lists only tickets in the same company as the change, because the service would refuse anything else (below). A change with a NULL tenant_id belongs to the Default company, and Default-company tickets can have NULL too, hence the OR t.tenant_id IS NULL branch. A single-company install skips the block entirely.
  • Not already linked, through the NOT IN subquery on this change's own links.
  • An optional ?q= adds t.ticket_number LIKE ? OR t.subject LIKE ?.

The select is where #245 added the type:

$sql = "SELECT t.id, t.ticket_number, t.subject, t.created_datetime,
               ts.name AS status, tt.name AS ticket_type, COALESCE(NULLIF(TRIM(u.display_name),''), u.email) AS requester
        FROM tickets t
        LEFT JOIN ticket_statuses ts ON ts.id = t.status_id
        LEFT JOIN ticket_types tt ON tt.id = t.ticket_type_id
        LEFT JOIN users u ON u.id = t.user_id
        $where
        ORDER BY t.created_datetime DESC
        LIMIT 200";

LEFT JOIN, not JOIN: tickets.ticket_type_id is nullable, and a ticket with no type (or a type since deleted) must still be offered, just without a pill. The requester falls back from a blank display name to the email address.

Showing the links: get.php

api/change-management/get.php returns the links inside the change:

// Linked tickets - any type, not only incidents (GH #245); the key stays 'incidents'
// because the screen and older callers read it. Degrades to [] if the table isn't present yet.
$change['incidents'] = [];
try {
    $incSql = "SELECT t.id, t.ticket_number, t.subject, ts.name AS status, tt.name AS ticket_type
               FROM change_tickets ct
               JOIN tickets t ON t.id = ct.ticket_id
               LEFT JOIN ticket_statuses ts ON ts.id = t.status_id
               LEFT JOIN ticket_types tt ON tt.id = t.ticket_type_id
               WHERE ct.change_id = ? AND t.deleted_datetime IS NULL
               ORDER BY t.created_datetime DESC";

Unlike the picker, closed tickets are shown - a link made while a ticket was open stays visible after it closes. Trashed tickets are hidden but the link row survives, so restoring the ticket from the trash brings the link back.

TRAP: the response key is incidents and stays incidents. renderLinkedIncidents() reads c.incidents, and anything outside the screen that reads get.php may too. The field was not renamed with the wording; it now holds tickets of every type.

Writing the links: ChangesService::linkTicket() / unlinkTicket()

api/change-management/link_ticket.php and unlink_ticket.php are thin adapters (they also accept a ticket_number, looked up to an id). The rules are in includes/services/changes.php:

// Same-company rule: NULL normalises to Default.
if (isMultiTenant($conn)) {
    $default = getDefaultTenantId($conn);
    $cTid = $change['tenant_id'] === null ? $default : (int)$change['tenant_id'];
    $tTid = $ticket['tenant_id'] === null ? $default : (int)$ticket['tenant_id'];
    if ($cTid !== $tTid) {
        throw new ServiceError('validation', 'invalid_field', 'That ticket belongs to a different company than this change.');
    }
}

Before that, linkTicket() calls assertCanWrite(), loads the change through loadJoined() (a change outside the actor's company scope is a 404), and checks the actor can see the ticket too (canAccessTenantRow($conn, $ctx, 'tickets', $ticketId)). The same-company rule is a separate, stricter check: an analyst who can see both companies still cannot link a ticket in one to a change in the other. A duplicate is refused with a conflict before the insert, so the unique key is never the thing that answers.

Then the link and its audit row:

$conn->prepare("INSERT INTO change_tickets (change_id, ticket_id, created_by_id, created_datetime) VALUES (?, ?, ?, UTC_TIMESTAMP())")
     ->execute([$changeId, $ticketId, $actorId]);
// TRAP: 'Linked incident' is a stored marker, not wording - the timeline matches it
// (change-management.js) and shows the translated "linked ticket". Renaming it would
// orphan every existing history row; the screen went neutral in GH #245, this stays.
self::auditWrite($conn, $changeId, $actorId, 'field_change', 'Linked incident', null, $ticket['ticket_number']);

unlinkTicket() reads the ticket number first (falling back to #<id>), deletes the row (404 Link not found. when nothing was deleted), and writes the mirror image: the same field_name, with the number in old_value and new_value NULL:

self::auditWrite($conn, $changeId, $ctx->actorId, 'field_change', 'Linked incident', $ticketNumber, null);

Both touch changes.modified_datetime.

TRAP: 'Linked incident' is a marker, not a label

The audit trail stores field_name = 'Linked incident' for both directions. The activity timeline in assets/js/change-management.js (renderActivityTimeline()) recognises the rows by that exact string and draws them in the reader's language:

} else if (entry.field_name === 'Linked incident') {
    description = entry.new_value
        ? `${window.t('change-management.detail.linked_incident')} <span class="new-val">${escapeHtml(entry.new_value)}</span>`
        : `${window.t('change-management.detail.unlinked_incident')} <span class="old-val">${escapeHtml(entry.old_value)}</span>`;

new_value set means linked, old_value set means unlinked. The keys kept their old names too; their English is now 'linked_incident' => 'linked ticket' and 'unlinked_incident' => 'unlinked ticket' in lang/en/change-management.php.

Do not rename the stored string. Every change on every install already has rows saying Linked incident. Change the service to write Linked ticket and the old rows stop matching, falling through to the generic changed <strong>Linked incident</strong> to ... line, while new rows show differently from old ones. To change the wording, change the translation, never the marker. The same reasoning kept the JS function names (renderLinkedIncidents(), linkChangeIncident(), unlinkChangeIncident()) and the CSS classes (.linked-incidents-table and friends).

The screen: renderLinkedIncidents() and the type pill

Each linked ticket's row now carries its type beside the number, only when there is one:

<td><a href="../tickets/index.php?ticket_id=${i.id}" target="_blank" rel="noopener">${escapeHtml(i.ticket_number || ('#' + i.id))}</a>${i.ticket_type ? ` <span class="linked-ticket-type">${escapeHtml(i.ticket_type)}</span>` : ''}</td>

The type name is set by an administrator, so it is escaped like everything else in the row. The picker (loadLinkableIncidents()) shows it in the meta line between the number and the status: ${t.ticket_type ? ' &middot; ' + escapeHtml(t.ticket_type) : ''}.

The pill, in assets/css/change-management.css, uses theme variables with light fallbacks so it follows dark mode:

.linked-ticket-type {
    display: inline-block;
    margin-left: 6px;
    padding: 1px 8px;
    border-radius: 10px;
    background: var(--surface-2, #f3f4f6);
    border: 1px solid var(--border, #e5e7eb);
    color: var(--text-muted, #6b7280);
    font-size: 12px;
    font-weight: 500;
    white-space: nowrap;
}

Testing it

No test in tests/ covers linked tickets. By hand, on a test install:

  1. Link a ticket of a non-incident type to a change: the picker shows its type in the meta line, the row shows the pill, and the timeline says linked ticket. Unlink it: unlinked ticket.
  2. A change whose history has links from before 4.9.0 still shows them as linked ticket lines, not as changed Linked incident - the marker check.
  3. A ticket with no type links and shows with no pill.
  4. Multi-company: a change in company A lists only company A's tickets; posting another company's ticket id to link_ticket.php is refused.
  5. Switch to dark mode and check the pill is readable.

REST API

api/v1/resources/changes.php registers its routes in api/v1/lib/routes.php and calls the same ChangesService methods with ActorContext::fromApiKey($apiKey), so every rule above applies to the API too. Changes are company-scoped there as well (changes.tenant_id). Usage: REST API: Changes.

Elsewhere in the product

  • Watchtower (includes/watchtower_queries.php) counts awaiting changes by stage and open emergency rounds through changeApprovalEmergencyOpenCount().
  • Problem Management links a change to a problem through change_relations.
  • Projects read changes for their links and the "change approved" gate (includes/projects/gatecheck.php, includes/projects/links.php).
  • Workflows hear change.created, change.approved, change.submitted, change.resubmitted, change.rejected, change.approval_withdrawn and change.deleted - see Part 1 §11 and Workflows.

Related pages

⚠️ **GitHub.com Fallback** ⚠️