13 KiB
Design Document: Remediation Plan History
Overview
Adds an append-only audit trail for resolution_date and remediation_plan changes on compliance items. The design preserves the existing compliance_items schema (current values remain directly queryable) and introduces a new compliance_item_history table for historical entries. The pattern mirrors how compliance_notes works — separate rows with timestamps and attribution.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ ComplianceDetailPanel │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ Resolution │ │ Remediation │ │ Change Reason (text) │ │
│ │ Date Input │ │ Plan Input │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
│ │ │ │ │
│ └──────────────────┴──────────────────────┘ │
│ │ │
│ PATCH /metadata │
│ { resolution_date, remediation_plan, │
│ change_reason } │
│ │ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Change History Section │ │
│ │ [date] [field] [old→new] [by user] [reason] │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Backend (compliance.js) │
│ │
│ PATCH /items/:hostname/metadata │
│ 1. Validate inputs │
│ 2. SELECT current values from compliance_items │
│ 3. Compare old vs new — skip if identical │
│ 4. INSERT into compliance_item_history (per changed field) │
│ 5. UPDATE compliance_items with new values │
│ │
│ GET /items/:hostname │
│ (existing) + SELECT from compliance_item_history │
│ LIMIT 10 ORDER BY changed_at DESC │
│ │
│ POST /vcl/bulk-commit │
│ For each hostname: same compare-then-insert pattern │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ │
│ compliance_items (unchanged) │
│ resolution_date DATE │
│ remediation_plan TEXT │
│ │
│ compliance_item_history (new) │
│ id, hostname, field_name, old_value, new_value, │
│ change_reason, changed_by, changed_at │
└─────────────────────────────────────────────────────────────────┘
Data Model
New Table: compliance_item_history
CREATE TABLE IF NOT EXISTS compliance_item_history (
id SERIAL PRIMARY KEY,
hostname TEXT NOT NULL,
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
old_value TEXT,
new_value TEXT,
change_reason TEXT,
changed_by TEXT NOT NULL,
changed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
ON compliance_item_history(hostname, field_name);
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
ON compliance_item_history(changed_at);
Existing Table: compliance_items (no changes)
The resolution_date and remediation_plan columns remain as-is. They continue to hold the current/latest value for direct querying by VCL reports.
API Changes
PATCH /api/compliance/items/:hostname/metadata
Request body changes:
{
"resolution_date": "2026-03-15",
"remediation_plan": "Upgrade firmware to v4.2",
"change_reason": "Vendor pushed back delivery date"
}
New optional field: change_reason (string, max 500 characters, nullable).
Behavior changes:
-
Before updating compliance_items, query the current values:
SELECT DISTINCT ON (hostname) resolution_date, remediation_plan FROM compliance_items WHERE hostname = $1 AND status = 'active' ORDER BY hostname, id DESC LIMIT 1 -
For each field being updated, compare old vs new. If different, insert a history row:
INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by) VALUES ($1, $2, $3, $4, $5, $6) -
Then proceed with the existing UPDATE as before.
Response: unchanged ({ updated: number }).
GET /api/compliance/items/:hostname
Response changes:
Add a history array to the response:
{
"hostname": "server01.example.com",
"ip_address": "10.0.1.5",
"device_type": "Server",
"team": "STEAM",
"resolution_date": "2026-03-15",
"remediation_plan": "Upgrade firmware to v4.2",
"metrics": [...],
"notes": [...],
"history": [
{
"id": 42,
"field_name": "resolution_date",
"old_value": "2026-02-01",
"new_value": "2026-03-15",
"change_reason": "Vendor pushed back delivery date",
"changed_by": "jsmith",
"changed_at": "2026-01-20T14:30:00.000Z"
},
{
"id": 41,
"field_name": "remediation_plan",
"old_value": null,
"new_value": "Upgrade firmware to v4.2",
"change_reason": null,
"changed_by": "jsmith",
"changed_at": "2026-01-15T09:00:00.000Z"
}
]
}
Query:
SELECT id, field_name, old_value, new_value, change_reason, changed_by, changed_at
FROM compliance_item_history
WHERE hostname = $1
ORDER BY changed_at DESC
LIMIT 10
POST /api/compliance/vcl/bulk-commit
Behavior changes:
Within the transaction, before updating each hostname:
- Query current values for that hostname
- Compare each field (resolution_date, remediation_plan) against the incoming value
- Insert history rows for changed fields with
changed_byset toreq.user.username
No request/response shape changes. The change_reason is not supported for bulk updates (would require per-row reasons which adds complexity without clear value for mass imports).
Frontend Changes
ComplianceDetailPanel.js
New state:
changeReason(string) — text input value for the reason fieldhistory(array) — populated from the API response
New UI elements:
-
Change Reason input — a single-line text input placed between the remediation plan save button and the notes section. Cleared after each successful save.
-
Change History section — a new
<Section>component placed between the Remediation Plan section and the Notes section. Displays up to 10 history entries in reverse chronological order.
History entry display format:
[field_name icon] old_value → new_value
username · 2026-01-20 [reason if present]
- Resolution date entries: show dates as YYYY-MM-DD, NULL shown as "—"
- Remediation plan entries: truncate old/new values to 60 characters with "…" suffix; full text shown on hover via title attribute
- Change reason: displayed in muted text below the change line when present
Save flow update:
- The
handleSaveMetadatafunction passeschange_reasonalongside the field values - After successful save, clear the
changeReasoninput and re-fetch detail (which now includes updated history)
Error Handling
- If the history INSERT fails, the entire PATCH operation should fail (history is part of the same logical operation). Use a transaction wrapping both the history insert and the compliance_items update.
- If the history SELECT for the GET endpoint fails, return the device detail without history (graceful degradation) and log the error.
change_reasonvalidation: max 500 characters, trimmed. If over 500, return 400 with descriptive error.
Migration
File: backend/migrations/add_compliance_item_history.js
const pool = require('../db');
async function run() {
console.log('Starting compliance_item_history migration...');
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS compliance_item_history (
id SERIAL PRIMARY KEY,
hostname TEXT NOT NULL,
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
old_value TEXT,
new_value TEXT,
change_reason TEXT,
changed_by TEXT NOT NULL,
changed_at TIMESTAMPTZ DEFAULT NOW()
)
`);
console.log('✓ compliance_item_history table created (or already exists)');
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
ON compliance_item_history(hostname, field_name)
`);
console.log('✓ hostname/field_name index created');
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
ON compliance_item_history(changed_at)
`);
console.log('✓ changed_at index created');
console.log('Migration complete.');
} catch (err) {
console.error('Migration failed:', err.message);
throw err;
}
}
module.exports = { run };
Reporting Isolation
No changes to VCL reporting queries. The following queries continue to read directly from compliance_items.resolution_date:
- Donut chart:
SELECT hostname, MAX(resolution_date) FROM compliance_items WHERE status = 'active' GROUP BY hostname - Burndown forecast:
SELECT resolution_date FROM compliance_items WHERE status = 'active' AND resolution_date IS NOT NULL - Per-vertical burndown in vclMultiVertical.js
The compliance_item_history table is never referenced by any reporting query.
Performance Considerations
- History inserts are lightweight (single row per field change). Even bulk updates with 500 hostnames produce at most 1000 history rows per commit — well within PostgreSQL's transaction capacity.
- The LIMIT 10 on history retrieval prevents unbounded result sets for devices with many changes.
- Indexes on (hostname, field_name) and (changed_at) ensure fast lookups without full table scans.
- No additional queries are added to the VCL reporting paths.