Approvals and Evidence 2 Change Text History - edmozley/freeitsm GitHub Wiki

Approvals and evidence 2 - change text history

Status: proposal, not built. Part 2 of the #180 design. Anything marked today describes 3.7.0; everything else is the plan.

What #180 asks for:

  • Keep the complete previous and new versions of the long-text fields, with the editor and the time, available in the history and in an authorised export.
  • Complete content, not a truncated summary - a summary may link to the full revision.
  • Save the content and its revision consistently, so a successful edit can never leave a revision missing.
  • Approvals can point at the revision that was reviewed. Versions that were never recorded must not be passed off as history.

This part has no settings. It only adds records, and adding records takes nothing away from anybody.


1. What is kept today

change_audit records every short field: who, when, old value, new value. Its old_value / new_value columns are VARCHAR(1000), and ChangesService::updateChange() cuts each value to 200 characters before writing it ($auditVal).

The eight long-text fields are not recorded at all, deliberately:

// includes/services/changes.php - updateChange()
// Longtext bodies — updated but NOT audited (same as the UI).
foreach (['description', 'reason_for_change', 'risk_evaluation', 'test_plan',
          'rollback_plan', 'post_implementation_review', 'pir_lessons_learned', 'pir_follow_up'] as $field) {

That was a sensible choice for an audit table whose columns hold a thousand characters. A rollback plan can be ten thousand, and truncating it would produce something worse than nothing: a record that looks complete and is not.

Field Column What the editor saves
Description description HTML (TinyMCE)
Reason for change reason_for_change HTML
Risk evaluation risk_evaluation HTML
Test plan test_plan HTML
Rollback plan rollback_plan HTML
Post-implementation review post_implementation_review HTML
Lessons learned pir_lessons_learned plain text (a <textarea>)
Follow-up pir_follow_up plain text

All eight are LONGTEXT NULL in database/freeitsm.sql and includes/db_verify_schema.php.


2. A table of its own

CREATE TABLE change_text_revisions (
  id              BIGINT AUTO_INCREMENT PRIMARY KEY,
  change_id       INT NOT NULL,             -- FK changes ON DELETE CASCADE
  field_key       VARCHAR(40) NOT NULL,     -- 'test_plan', … ; 'comment' for a deleted comment (§7)
  content         LONGTEXT NULL,            -- the WHOLE value after this save; NULL = cleared
  content_format  VARCHAR(10) NOT NULL,     -- 'html' | 'text'
  content_hash    CHAR(64) NOT NULL,        -- sha256 of content ('' for NULL)
  is_baseline     TINYINT(1) NOT NULL DEFAULT 0,  -- 1 = "as found when history began", not an edit
  editor_id       INT NULL,                 -- FK analysts ON DELETE SET NULL; NULL for a baseline
  source          VARCHAR(10) NOT NULL,     -- 'ui' | 'api' | 'system' | 'upgrade'
  created_datetime DATETIME NOT NULL,       -- UTC
  KEY (change_id, field_key, id)
);

One row per field per save that changed it, holding the new value in full. The previous version is simply the row before it for the same field, so "previous and new" never have to be stored twice and can never disagree.

Why not widen change_audit? Because it is read by the activity timeline, GET /changes/{id}/audit, and anything a customer has built on that endpoint. Putting ten-thousand-character HTML bodies into it would change what all of those return, and the timeline would have to learn to fold them. A separate table keeps the audit trail exactly as it is and makes the revision table something that is read on purpose.

Why not one row per save holding all eight fields? Most saves change one field. Storing eight copies to record one edit multiplies the size by eight and makes "show me every version of the test plan" a scan of rows that mostly did not change it.


3. Writing a revision - in the same transaction

updateChange() already works out which long fields changed ($newVal !== $current[$field]). From now on, for each one:

// inside the transaction opened by updateChange() (Part 1 §5)
foreach ($changedLongFields as $field => $newVal) {
    ChangeRevisions::ensureBaseline($conn, $changeId, $field, $current[$field]);   // §4
    $revId = ChangeRevisions::write($conn, $changeId, $field, $newVal, $ctx);
    $audits[] = ['text_edit', changeFieldLabel($field), null, "Revision #{$revId}"];
}
// … the single UPDATE changes …, the audit rows …, then commit

Consistency is the transaction. The UPDATE changes, the revision rows and the audit rows commit together or not at all. Today there is no transaction in ChangesService (F5 on the overview), so this part introduces it - which is the reason Part 2 is built before Part 1.

🔑 A revision is written by the service, never by an adapter. The edit form, the mobile card editor, PATCH /changes/{id} and anything added later all reach updateChange(), so they all get revisions without knowing revisions exist. Writing them in api/change-management/save.php would leave the API without history - the exact drift the service layer exists to prevent.

The audit row is the summary that links to the full revision. It reads "Test plan edited - Revision #812", with action_type = 'text_edit', so the timeline gains one line per edit and the existing field_change rows are untouched.

Creating a change writes one revision for each long field that is not empty, source = how it was created. A change created after the upgrade therefore has complete history from its first save, with no baseline needed.

What counts as a change is today's test, after today's trim(): the stored value differs from the new one. The editor sending identical HTML back produces nothing, as today. A change in whitespace inside the HTML is a new revision; the content is what was saved, not an opinion about whether it matters.

Revisions are never edited or deleted by any endpoint. There is no update path to protect, because there is no update path.


4. The baseline - what was there before

A change that existed before the upgrade has text whose history was never recorded. #180 is right that this must not be presented as evidence.

So the first time a field is touched after the upgrade - edited, or captured into an approval snapshot - ensureBaseline() first writes the current value as a row with is_baseline = 1, editor_id = NULL, source = 'upgrade', and the time it was found.

It is shown as:

As found on 15 Oct 2026, when history began. FreeITSM did not keep earlier versions of this text, so who wrote it and when is not known.

It is never labelled "original", never attributed to anybody, and never dated with the change's creation date.

Why lazily, on first touch, and not at upgrade time? Writing a baseline for every field of every change during Database Verify is a large write on a live database, for text most of which will never be edited again. Lazy baselines cost nothing until needed, and the result is identical: the first edit always has a before.


5. What people see

  • Activity timeline - a line per edit: "Sam edited the Test plan" with View.
  • The revision viewer (a modal) - the field's versions down the side, newest first, baseline last and labelled. Choose one and see it beside the version before it, with the editor and the time of each. Compare any two.
  • Diffs - words added and removed, highlighted, worked out on the text content (HTML stripped for the comparison, rendered for reading). Written in plain JS in assets/js/change-revisions.js; no library.
  • From an approval - a superseded or approved round links to "what changed since this was approved", which is the same viewer comparing the round's snapshot revision with the current one (Part 1 §6).
  • On a phone - the viewer is a full-screen panel with the two versions stacked rather than side by side, following the mobile techniques.

Rendering safety. A revision is author-written HTML, shown to somebody else. Every version is put through the shared cleaner, assets/js/safe-html.js (messageBodyHtml() with the stored content_format), and never written to innerHTML raw. There is exactly one HTML cleaner in the browser and this feature does not get a second one.


6. The evidence export

#180 asks for history "in an authorised export". Today's export is a client-side PDF (shareChangePdf() → buildPdfContent() in assets/js/change-management.js) that holds the current text of six fields and no history.

New: Export evidence, on the change's menu next to Export as PDF. A server-rendered, printable page at change-management/evidence.php?id= (print to PDF from the browser), containing:

  1. the change as it stands, every field;
  2. every approval round - route, state, who decided, each vote and whether it counted, the snapshot each was cast against (Part 1);
  3. every revision of every long field, in full, oldest first, baselines labelled as in §4;
  4. the full audit trail and all comments, including deleted ones (§7);
  5. attachments by name, size, uploader, time and sha256 (§7) - the files themselves stay in the change.

Why server-rendered and not the existing jsPDF/html2pdf route: an evidence pack can run to hundreds of pages of HTML revisions, which is where a browser-side PDF engine struggles, and it must be the same document whoever produces it. The server decides what goes in; the browser only prints it.

"Authorised" means: the analyst can open the change (module access - Full or Read only, Part 3 - and the change's company, through analystCanAccessChange()). The export is itself written to change_audit ("Evidence exported by Priya"), because who took a copy of the evidence is part of the evidence.


7. The two other gaps in the trail

Found while mapping this (F9 on the overview); small, and they belong here.

Attachments. api/change-management/upload_attachment.php and api/change-management/delete_attachment.php write nothing to change_audit, although the help page suggests attaching "approval emails". Both now write an audit row naming the file, and uploads store a sha256 on change_attachments, so the evidence export can show that the file in the change is the file that was attached.

Deleted comments. ChangesService::deleteComment() hard-deletes a comment with no trace. It now copies the comment into change_text_revisions (field_key = 'comment', the comment's author and date kept in the row) and writes "Comment deleted by Sam" to the audit trail. The comment disappears from the timeline; it does not disappear from the evidence.


8. REST API

Additive only.

Endpoint Permission Returns
GET /changes/{id}/revisions change_revisions.read ?field=test_plan optional. Each revision's id, field, editor, source, is_baseline, created_at, content_hash, length - no content, so a list is cheap
GET /changes/{id}/revisions/{revision_id} change_revisions.read the one revision with its full content
GET /changes/{id}/audit (today) change_audit.read gains text_edit rows, whose new_value is "Revision #812"

PATCH /changes/{id} is unchanged in what it accepts and returns; it now leaves revisions behind it.


9. Size

A revision is a full copy, not a diff. That is deliberate: a chain of diffs is only as good as its weakest link, and "the complete previous and new versions" is the requirement.

Rough cost: a busy install raising 50 changes a month, each with six rich fields of around 4 KB edited three times, adds about 3.6 MB a month. Revisions are deleted with their change (ON DELETE CASCADE, as change_audit is today); whether an approved change should be deletable at all is open question 8 on the overview.


10. The files

Colour key: 🗄️ schema · ⚙️ engine · 🔌 API · 🖥️ UI · 🌍 i18n · 📄 docs · 🧪 test

🎨 File What changes
🗄️ includes/db_verify_schema.php · database/freeitsm.sql change_text_revisions; change_attachments.sha256
⚙️ includes/changes/revisions.php (new) ChangeRevisions::write(), ensureBaseline(), listFor(), get(), fieldFormat()
⚙️ includes/services/changes.php the transaction; revisions on create and update; text_edit audit rows; deleteComment() keeps the text
🔌 api/change-management/get_revisions.php · api/change-management/get_revision.php (new) the viewer's two reads, behind analystCanAccessChange()
🔌 api/change-management/upload_attachment.php · api/change-management/delete_attachment.php audit rows; the sha256
🖥️ change-management/evidence.php (new) the evidence export
🖥️ assets/js/change-revisions.js (new) · assets/js/change-management.js · assets/css/change-management.css the viewer, the diff, the timeline lines, the menu item
🔌 api/v1/resources/changes.php · api/v1/lib/routes.php · api/v1/lib/permissions.php · api/v1/lib/openapi.php the two endpoints; change_revisions.read
🖥️ change-management/help.php Quick tips → Audit trail rewritten to say what is true
🌍 lang/en/change-management.php (+ the locales) new strings
📄 wiki: Change Management, REST API: Changes
🧪 tests/change-revisions.php (new) below

11. How it will be tested

On a temporary ZZ- change that deletes itself.

Test Proves
Save a test plan, replace it, save again two revisions; the viewer shows both in full; the audit row links the second
A save that fails after the text update (a forced ServiceError later in updateChange()) no revision and no text change - the transaction rolled both back
Edit an existing change for the first time after the upgrade a baseline row first, labelled, no editor; then the edit
PATCH the test plan through the API the same revisions as the UI, source = 'api'
Open a revision containing hostile markup in the viewer it is rendered through the shared cleaner, never raw
Delete a comment gone from the timeline, present in the evidence export
Export evidence as an analyst without the change's company 404, as every other by-id read
⚠️ **GitHub.com Fallback** ⚠️