Approvals and Evidence 2 Change Text History - edmozley/freeitsm GitHub Wiki
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.
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.
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.
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 commitConsistency 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 reachupdateChange(), so they all get revisions without knowing revisions exist. Writing them inapi/change-management/save.phpwould 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.
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.
- 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.
#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:
- the change as it stands, every field;
- every approval round - route, state, who decided, each vote and whether it counted, the snapshot each was cast against (Part 1);
- every revision of every long field, in full, oldest first, baselines labelled as in §4;
- the full audit trail and all comments, including deleted ones (§7);
- 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.
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.
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.
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.
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 |
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 |