# 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. **Per-metric extension (Requirements 8–15):** The existing PATCH endpoint updates all active rows for a hostname uniformly. Since `compliance_items` already stores `resolution_date` and `remediation_plan` per row (each row is a hostname+metric_id pair), the extension allows targeting specific metrics via optional `metric_id`/`metric_ids` parameters. This mirrors the multi-metric notes pattern established in the `compliance-multi-metric-notes` spec — same chip selector UI, same Select All toggle, same `metric_ids` array API convention. ## 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 │ └─────────────────────────────────────────────────────────────────┘ ``` ## Components and Interfaces ### Backend Components - **PATCH /api/compliance/items/:hostname/metadata** — updates resolution_date and/or remediation_plan on compliance_items rows, records field-level change history. Extended with optional `metric_id`/`metric_ids` for per-metric scoping. - **GET /api/compliance/items/:hostname** — returns device detail including history entries (with metric_id field). - **POST /api/compliance/vcl/bulk-commit** — bulk update path that also records history per hostname. ### Frontend Components - **ComplianceDetailPanel.js** — slide-out panel displaying device compliance detail, metadata editing, change history, and notes. - **MetricChipSelector (metadata)** — chip-based multi-select for choosing which metrics a resolution_date/remediation_plan update applies to. Positioned above the metadata inputs. - **HistoryMetricLabel** — renders a MetricChip for per-metric history entries or "All metrics" label for hostname-level entries. ## Data Models ### 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. --- ## Per-Metric Extension (Requirements 8–15) ### Overview The `compliance_items` table already stores `resolution_date` and `remediation_plan` per row, where each row represents a unique (hostname, metric_id) pair. The current PATCH endpoint updates ALL active rows for a hostname uniformly. This extension adds optional metric scoping so analysts can set different resolution dates and remediation plans for individual metrics on the same device. The UI pattern replicates the multi-metric notes selector from the `compliance-multi-metric-notes` spec: chip-based multi-select with Select All toggle, positioned above the resolution_date and remediation_plan inputs. ### Architecture — Per-Metric Flow ```mermaid sequenceDiagram participant User participant Panel as ComplianceDetailPanel participant API as PATCH /items/:hostname/metadata participant DB as PostgreSQL User->>Panel: Open detail panel for hostname Panel->>Panel: Pre-select all active metrics (Select All default) User->>Panel: Deselect some metrics, select specific ones Panel->>Panel: Compute shared values for selected metrics Panel->>Panel: Display shared value or "Multiple values" placeholder User->>Panel: Edit resolution_date / remediation_plan User->>Panel: Click Save alt All metrics selected (Select All) Panel->>API: PATCH { resolution_date, remediation_plan, change_reason } Note over Panel,API: No metric_ids → hostname-level update (backward compat) else Specific metrics selected Panel->>API: PATCH { resolution_date, remediation_plan, change_reason, metric_ids: [...] } end API->>API: Validate metric_ids against active items API->>DB: SELECT current values per targeted metric loop For each targeted metric with changed values API->>DB: INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, ...) end API->>DB: UPDATE compliance_items SET ... WHERE hostname = $1 AND metric_id = ANY($2) API-->>Panel: { updated: N } Panel->>Panel: Re-fetch detail, refresh history ``` ### Schema Extension #### Add metric_id column to compliance_item_history ```sql ALTER TABLE compliance_item_history ADD COLUMN metric_id TEXT; CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_metric ON compliance_item_history(hostname, metric_id); ``` The column is nullable. Existing rows retain `NULL` metric_id, indicating they were hostname-level changes made before this extension. New per-metric changes populate the column; hostname-level changes (no metric_ids in request) continue to insert with `NULL`. #### Migration File: `backend/migrations/add_compliance_history_metric_id.js` ```javascript const pool = require('../db'); async function run() { console.log('Starting compliance_item_history metric_id migration...'); try { // Add nullable metric_id column (idempotent check) const { rows } = await pool.query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'compliance_item_history' AND column_name = 'metric_id' `); if (rows.length === 0) { await pool.query(`ALTER TABLE compliance_item_history ADD COLUMN metric_id TEXT`); console.log('✓ metric_id column added to compliance_item_history'); } else { console.log('✓ metric_id column already exists'); } await pool.query(` CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_metric ON compliance_item_history(hostname, metric_id) `); console.log('✓ (hostname, metric_id) index created'); console.log('Migration complete.'); } catch (err) { console.error('Migration failed:', err.message); throw err; } } module.exports = { run }; // Self-execute when run directly if (require.main === module) { run().then(() => process.exit(0)).catch(() => process.exit(1)); } ``` No backfill is performed — pre-existing history rows with `NULL` metric_id correctly represent hostname-level changes. ### API Changes — Per-Metric Scoping #### PATCH /api/compliance/items/:hostname/metadata (extended) **New optional request body fields:** ```json { "resolution_date": "2026-03-15", "remediation_plan": "Upgrade firmware to v4.2", "change_reason": "Vendor pushed back delivery date", "metric_id": "2.1.1", "metric_ids": ["2.1.1", "2.3.2"] } ``` | Field | Type | Description | |---|---|---| | `metric_id` | string (optional) | Scope update to a single metric | | `metric_ids` | string[] (optional) | Scope update to multiple specific metrics | **Precedence:** If both `metric_id` and `metric_ids` are provided, `metric_ids` wins and `metric_id` is ignored. **Validation:** - Each metric_id must be a non-empty string of 100 characters or fewer - Each metric_id must correspond to an active compliance_item for the hostname — if any provided metric_id has no matching active row, return 400 with `{ error: "Invalid metric_id: — no active compliance item found" }` **Behavior by scoping mode:** | Mode | Condition | Query target | History metric_id | |---|---|---|---| | Hostname-level | Neither `metric_id` nor `metric_ids` provided | All active rows for hostname | `NULL` | | Single metric | `metric_id` provided (no `metric_ids`) | Single row matching hostname + metric_id | The metric_id value | | Multi-metric | `metric_ids` provided | Rows matching hostname + any of the metric_ids | Per-row metric_id | **Per-metric history insertion logic:** When metric scoping is active, the endpoint queries current values per targeted metric individually (since they may differ), then inserts one history row per metric per changed field: ```sql -- Get current values for each targeted metric SELECT metric_id, resolution_date, remediation_plan FROM compliance_items WHERE hostname = $1 AND metric_id = ANY($2) AND status = 'active' ``` ```sql -- Insert history per metric per changed field INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by) VALUES ($1, $2, $3, $4, $5, $6, $7) ``` ```sql -- Update only targeted rows UPDATE compliance_items SET resolution_date = $1, remediation_plan = $2 WHERE hostname = $3 AND metric_id = ANY($4) AND status = 'active' ``` **Response:** unchanged (`{ updated: number }`). #### GET /api/compliance/items/:hostname (extended history response) The history query now includes `metric_id`: ```sql SELECT id, hostname, metric_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 ``` Response shape — history entries now include `metric_id`: ```json { "history": [ { "id": 55, "metric_id": "2.1.1", "field_name": "resolution_date", "old_value": "2026-02-01", "new_value": "2026-04-15", "change_reason": "Vendor delay on patch", "changed_by": "jsmith", "changed_at": "2026-02-10T11:00:00.000Z" }, { "id": 54, "metric_id": null, "field_name": "remediation_plan", "old_value": "Upgrade firmware", "new_value": "Replace hardware", "change_reason": null, "changed_by": "admin", "changed_at": "2026-02-05T09:00:00.000Z" } ] } ``` ### Frontend Changes — Per-Metric Metadata Selector #### New State ```javascript const [metricSelection, setMetricSelection] = useState([]); // metric_ids for metadata editing ``` This is separate from the existing `selectedMetrics` state (used for notes). The metadata metric selector has its own selection state. #### UI Layout The Metric_Selector is placed above the resolution_date and remediation_plan inputs, matching the notes section pattern: ``` ComplianceDetailPanel ├── Header (hostname, IP, device type, team) ├── Section: Failing Metrics ├── Section: Resolved Metrics ├── Section: Remediation Plan & Resolution Date │ ├── MetricChipSelector (for metadata) ← NEW │ │ ├── Select All / Deselect All toggle │ │ └── MetricChip (per active metric, clickable) │ ├── Resolution Date input (populated from selection) │ ├── Remediation Plan textarea (populated from selection) │ ├── Change Reason input │ └── Save button ├── Section: Change History │ └── HistoryEntry (with optional MetricChip) ← EXTENDED ├── Section: Notes │ ├── NoteCard (grouped by group_id) │ └── Add Note Form (with its own MetricChipSelector) ``` #### MetricChipSelector Behavior (Metadata) | State | Behavior | |---|---| | 1 active metric | Chip is pre-selected and non-removable. No Select All toggle. | | 2+ active metrics, panel just opened | **All metrics pre-selected** (Select All default). Select All toggle visible. | | User clicks unselected chip | Chip added to selection | | User clicks selected chip (2+ selected) | Chip removed from selection | | User clicks selected chip (only 1 selected, 2+ metrics exist) | No-op — at least one must remain selected | | Select All clicked | All active metrics selected, toggle label changes to "Deselect All" | | Deselect All clicked | All metrics deselected except the first (minimum selection invariant) | **Key difference from notes selector:** The metadata selector defaults to "Select All" on panel open (preserving existing hostname-level behavior), while the notes selector defaults to the first metric only. #### Per-Metric Field Display Logic When the metric selection changes, the panel computes what to display in the resolution_date and remediation_plan inputs: ```javascript function computeSharedValues(metrics, selectedIds) { const selected = metrics.filter(m => selectedIds.includes(m.metric_id)); if (selected.length === 0) return { resolution_date: '', remediation_plan: '' }; if (selected.length === 1) { return { resolution_date: selected[0].resolution_date || '', remediation_plan: selected[0].remediation_plan || '', }; } // Multiple selected — check if values are uniform const dates = new Set(selected.map(m => m.resolution_date || '')); const plans = new Set(selected.map(m => m.remediation_plan || '')); return { resolution_date: dates.size === 1 ? [...dates][0] : null, // null = "Multiple values" remediation_plan: plans.size === 1 ? [...plans][0] : null, // null = "Multiple values" }; } ``` **Display rules:** | Computed value | Input state | |---|---| | Non-null string (including empty) | Input populated with that value | | `null` (multiple different values) | Input empty, placeholder text: "Multiple values" | **Save behavior with "Multiple values":** - If the user leaves an input untouched while it shows "Multiple values" placeholder, that field is NOT included in the PATCH request body — preserving existing per-metric values. - If the user types a new value into a "Multiple values" field, that value is sent and applied to all selected metrics. #### Save Flow — Metric Scoping ```javascript const handleSaveMetadata = async (fields) => { const body = { ...fields }; if (changeReason.trim()) body.change_reason = changeReason.trim(); // Determine if we need metric scoping const allSelected = activeMetrics.length > 0 && activeMetrics.every(m => metricSelection.includes(m.metric_id)); if (!allSelected) { // Specific metrics selected — include metric_ids body.metric_ids = metricSelection; } // If all selected, omit metric_ids → hostname-level update (backward compat) const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); // ... error handling, re-fetch ... }; ``` ### History Display — Per-Metric Extension History entries now optionally show which metric the change applied to: **Display format (extended):** ``` [MetricChip "2.1.1"] [field_name icon] old_value → new_value username · 2026-01-20 [reason if present] ``` Or for hostname-level changes (metric_id is null): ``` [All metrics] [field_name icon] old_value → new_value username · 2026-01-20 [reason if present] ``` **Rendering logic:** ```javascript function HistoryMetricLabel({ metricId, metricMap }) { if (metricId) { return ; } return ( All metrics ); } ``` ### Per-Metric Reporting The burndown and donut queries already read from individual `compliance_items` rows (each row has its own `resolution_date`). The per-metric extension does not change reporting queries — it only changes how the PATCH endpoint targets rows. **Existing burndown query (unchanged):** ```sql SELECT resolution_date, COUNT(*) as count FROM compliance_items WHERE status = 'active' AND resolution_date IS NOT NULL GROUP BY resolution_date ORDER BY resolution_date ``` Each row is already a (hostname, metric_id) pair, so per-metric resolution dates are naturally reflected in the burndown without query changes. **Aggregated hostname view (unchanged):** ```sql SELECT hostname, MAX(resolution_date) as resolution_date FROM compliance_items WHERE status = 'active' GROUP BY hostname ``` When deduplicating by hostname for summary views, the latest (MAX) resolution_date among all active metrics is used. ### Error Handling — Per-Metric Extension | Scenario | Response | Behavior | |---|---|---| | `metric_ids` contains a metric_id with no active row | 400 `{ error: "Invalid metric_id: — no active compliance item found" }` | No rows updated, no history inserted | | `metric_ids` is empty array | 400 `{ error: "metric_ids must contain at least one entry" }` | No rows updated | | `metric_id` or entry in `metric_ids` exceeds 100 chars | 400 `{ error: "metric_id exceeds 100 characters" }` | No rows updated | | `metric_id` or entry in `metric_ids` is empty string | 400 `{ error: "metric_id cannot be empty" }` | No rows updated | | Per-metric update targets metrics with different current values | N/A (success) | One history entry per metric per changed field | | All targeted metrics already have the new value | 200 `{ updated: 0 }` | No history entries created (no-change skip) | ### Backward Compatibility The per-metric extension is fully backward compatible: 1. **API:** Requests without `metric_id`/`metric_ids` follow the existing hostname-level path — update all active rows, insert history with `NULL` metric_id. 2. **Bulk commit:** Continues to apply values to all active metrics for each hostname. No `metric_ids` support in bulk (same as before). 3. **UI default:** Panel opens with all metrics selected. Saving without changing selection omits `metric_ids` from the request, triggering hostname-level behavior. 4. **History display:** Existing history entries (NULL metric_id) display as "All metrics" — no visual regression. 5. **Reporting:** No query changes. Per-row resolution_date was already the source of truth for burndown calculations. ## Correctness Properties ### Property 1: History entries are created only when values change *For any* PATCH request to `/items/:hostname/metadata` with a resolution_date or remediation_plan value, a history entry is inserted if and only if the new value differs from the current value on the targeted compliance_items row(s). Identical values produce zero history rows. **Validates: Requirements 1.4, 6.3** ### Property 2: Per-metric history records the correct old_value per row *For any* per-metric update targeting N metrics with potentially different current values, the system inserts one history entry per metric per changed field, and each entry's `old_value` matches the actual previous value of that specific compliance_items row — not a shared/aggregated value. **Validates: Requirements 11.4, 11.5** ### Property 3: Hostname-level updates produce NULL metric_id in history *For any* PATCH request without `metric_id` or `metric_ids`, all resulting history entries have `metric_id = NULL`, indicating the change applied to all active metrics for the hostname. **Validates: Requirements 11.3, 15.1** ### Property 4: Backward compatibility — omitting metric_ids updates all active rows *For any* PATCH request without `metric_id` or `metric_ids`, the number of compliance_items rows updated equals the count of active rows for that hostname. The behavior is identical to the pre-extension endpoint. **Validates: Requirements 8.6, 15.1, 15.2** ### Property 5: Invalid metric_ids reject the entire request atomically *For any* PATCH request where at least one entry in `metric_ids` does not correspond to an active compliance_item for the hostname, the endpoint returns 400 and no rows are updated, no history entries are created. **Validates: Requirements 8.7** ### Property 6: Select All default preserves hostname-level behavior *For any* panel open action on a hostname with N active metrics, the metadata metric selector defaults to all N metrics selected. Saving without changing the selection omits `metric_ids` from the request body, triggering the hostname-level update path. **Validates: Requirements 9.7, 15.3, 15.4** ### Property 7: Shared value display is a pure function of selection *For any* set of selected metrics, if all share the same resolution_date (or remediation_plan), the input displays that value. If any differ, the input shows "Multiple values" placeholder. This is deterministic and independent of selection order. **Validates: Requirements 10.2, 10.3, 10.4, 10.5** ## Testing Strategy ### Unit Tests **Backend:** - PATCH without metric_ids updates all active rows (backward compat) - PATCH with `metric_id` updates only the single matching row - PATCH with `metric_ids` updates only matching rows - Invalid metric_id returns 400 with descriptive error - History entries include correct metric_id (or NULL for hostname-level) - Per-metric update with different current values creates per-row history entries - Both `metric_id` and `metric_ids` provided — `metric_ids` wins **Frontend:** - MetricChipSelector defaults to all metrics selected on panel open - Shared value computation returns correct value when all metrics agree - Shared value computation returns null when metrics disagree - Save with all selected omits metric_ids from request body - Save with subset includes metric_ids in request body - History entries with metric_id render MetricChip - History entries with null metric_id render "All metrics" label ### Property-Based Tests Property-based tests use `fast-check` with a minimum of 100 iterations per property. - **Property 2: Per-metric history records correct old_value** — generate random sets of metrics with varying current values, apply a uniform update, verify each history entry's old_value matches the pre-update value for that specific metric. - **Property 4: Backward compatibility** — generate random hostnames with N active metrics, send PATCH without metric_ids, verify all N rows are updated. - **Property 7: Shared value display** — generate random arrays of metric objects with varying resolution_date/remediation_plan values, verify computeSharedValues returns the correct shared value or null. ### Integration Tests - End-to-end: open panel → select 2 of 4 metrics → change resolution_date → save → verify only 2 rows updated, history entries have correct metric_ids - Migration: verify metric_id column exists after running migration, existing rows retain NULL - Backward compat: existing bulk-commit flow continues to work without metric_ids ### Performance Considerations — Per-Metric Extension - Per-metric updates query current values with `WHERE metric_id = ANY($2)` — the new `(hostname, metric_id)` index on `compliance_item_history` ensures efficient lookups. - Worst case: a device with 15 active metrics, all selected, both fields changed = 30 history inserts per save. Still lightweight within a single transaction. - The `(hostname, metric_id)` index on the history table supports future filtering of history by metric if needed. - No additional queries added to reporting paths.