New specs: archer-template-library, ccp-metrics-view-restructure, compliance-list-stale-after-sidebar-edit, compliance-metric-estimated-resolution-date, compliance-remediation-display-fix, flexible-jira-ticket-creation, forecast-burndown-chart, granite-loader-export, ivanti-queue-clear-completed-fix, multi-item-jira-ticket, queue-collapsible-sections, vendor-issue-type-dropdown New steering: archer-template-gen.md Updated: migration-registration-check hook, remediation-plan-history spec, gitlab-workflow, tech, versioning steering files
34 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.
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_idsfor 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
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.
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
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
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
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:
{
"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: <value> — 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:
-- 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'
-- 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)
-- 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:
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:
{
"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
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:
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
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:
function HistoryMetricLabel({ metricId, metricMap }) {
if (metricId) {
return <MetricChip metricId={metricId} category={metricMap[metricId] || ''} />;
}
return (
<span style={{
fontSize: '0.68rem', color: '#64748B', fontStyle: 'italic',
padding: '0.15rem 0.4rem',
background: 'rgba(100,116,139,0.1)',
borderRadius: '0.2rem',
}}>
All metrics
</span>
);
}
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):
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):
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:
- API: Requests without
metric_id/metric_idsfollow the existing hostname-level path — update all active rows, insert history withNULLmetric_id. - Bulk commit: Continues to apply values to all active metrics for each hostname. No
metric_idssupport in bulk (same as before). - UI default: Panel opens with all metrics selected. Saving without changing selection omits
metric_idsfrom the request, triggering hostname-level behavior. - History display: Existing history entries (NULL metric_id) display as "All metrics" — no visual regression.
- 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_idupdates only the single matching row - PATCH with
metric_idsupdates 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_idandmetric_idsprovided —metric_idswins
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 oncompliance_item_historyensures 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.