# 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 ```sql 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:** ```json { "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:** 1. Before updating compliance_items, query the current values: ```sql SELECT DISTINCT ON (hostname) resolution_date, remediation_plan FROM compliance_items WHERE hostname = $1 AND status = 'active' ORDER BY hostname, id DESC LIMIT 1 ``` 2. For each field being updated, compare old vs new. If different, insert a history row: ```sql INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by) VALUES ($1, $2, $3, $4, $5, $6) ``` 3. 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: ```json { "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: ```sql 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: 1. Query current values for that hostname 2. Compare each field (resolution_date, remediation_plan) against the incoming value 3. Insert history rows for changed fields with `changed_by` set to `req.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 field - `history` (array) — populated from the API response **New UI elements:** 1. **Change Reason input** — a single-line text input placed between the remediation plan save button and the notes section. Cleared after each successful save. 2. **Change History section** — a new `
` 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 `handleSaveMetadata` function passes `change_reason` alongside the field values - After successful save, clear the `changeReason` input 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_reason` validation: max 500 characters, trimmed. If over 500, return 400 with descriptive error. ## Migration File: `backend/migrations/add_compliance_item_history.js` ```javascript 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.