Files
cve-dashboard/.kiro/specs/remediation-plan-history/design.md
Jordan Ramos a61d254ff9 Sync .kiro/ from master — v2.2.0 release batch
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
2026-06-04 11:27:31 -06:00

34 KiB
Raw Blame History

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 815): 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

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:

  1. 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
    
  2. 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)
    
  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:

{
  "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:

  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.

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 815)

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:

  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.