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.
- **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.
- **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.
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 `<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 `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.
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.
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
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`.
"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: <value> — no active compliance item found" }`
| 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:
│ └── 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) {
- 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();
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: <value> — 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.
- 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
- 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.