+ {/* Section Header */}
+
toggleSection(section.key)}
+ style={section.type === 'inventory'
+ ? STYLES.sectionHeaderInventory
+ : STYLES.sectionHeaderVendor}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ toggleSection(section.key);
+ }
+ }}
+ aria-expanded={!isCollapsed}
+ aria-label={`${section.label} section, ${section.items.length} items`}
+ >
+ {isCollapsed
+ ?
+ :
+ }
+ {section.label}
+ ({section.items.length})
+
+
+ {/* Section Body — only rendered when expanded */}
+ {!isCollapsed && section.items.map((item) => (
+ // ... existing queue item row rendering
+ ))}
+
+ );
+})}
+```
+
+## Styling
+
+New entries added to the existing `STYLES` constant:
+
+```javascript
+sectionHeaderInventory: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ padding: '0.5rem 0.75rem',
+ marginTop: '0.5rem',
+ borderBottom: '1px solid rgba(16, 185, 129, 0.2)',
+ cursor: 'pointer',
+ userSelect: 'none',
+ fontFamily: 'monospace',
+ fontSize: '0.7rem',
+ fontWeight: 700,
+ color: '#10B981',
+ textTransform: 'uppercase',
+ letterSpacing: '0.1em',
+},
+sectionHeaderVendor: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ padding: '0.5rem 0.75rem',
+ marginTop: '0.5rem',
+ borderBottom: '1px solid rgba(148, 163, 184, 0.15)',
+ cursor: 'pointer',
+ userSelect: 'none',
+ fontFamily: 'monospace',
+ fontSize: '0.7rem',
+ fontWeight: 700,
+ color: '#94A3B8',
+ textTransform: 'uppercase',
+ letterSpacing: '0.1em',
+},
+sectionCount: {
+ fontFamily: 'monospace',
+ fontSize: '0.65rem',
+ fontWeight: 600,
+ color: '#64748B',
+ marginLeft: '0.25rem',
+},
+```
+
+## Icon Imports
+
+Add `ChevronDown` and `ChevronRight` to the existing lucide-react import:
+
+```javascript
+import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight } from 'lucide-react';
+```
+
+## Select All Interaction with Collapsed Sections
+
+The existing `toggleSelectAll` and `allVisibleSelected` logic operates on the `visibleItems` array, which contains ALL pending items regardless of collapse state. This means:
+
+- **Select All** selects/deselects all pending items across all sections, whether those sections are collapsed or expanded.
+- **Selection count** always reflects `selectedIds.size`, which includes items in collapsed sections.
+- **Floating action bar** operates on `selectedQueueItems` (derived from `selectedIds`), which is independent of collapse state.
+
+No changes to the selection logic are needed. The collapse state is purely visual — it controls rendering, not data.
+
+## Error Handling
+
+- If `visibleItems` is empty, `groupedSections` will be an empty array, and the existing empty state renders instead.
+- If an item has an unexpected `workflow_type` (not CARD, GRANITE, DECOM, FP, or Archer), it falls into the vendor grouping path and is grouped by its vendor field. This is a safe fallback.
+- The `collapsedSections` state uses an object with string keys. Non-existent keys return `undefined`, which is falsy, so all sections default to expanded without explicit initialization.
+
+## Performance Considerations
+
+- The `useMemo` grouping computation runs only when `visibleItems` changes (on fetch or status update). For typical queue sizes (10–100 items), this is negligible.
+- Collapse state changes trigger re-renders only of the affected section's body. React's reconciliation handles this efficiently since each section has a stable `key`.
+- No additional API calls or data fetching is introduced.
+
+## Correctness Properties
+
+*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
+
+### Property 1: Grouping Correctness
+
+For any array of visible queue items, every item with workflow_type CARD, GRANITE, or DECOM SHALL appear in the Inventory section, every item with workflow_type FP or Archer SHALL appear in the vendor section matching its vendor field (or "Unknown" if vendor is null/empty), and no item SHALL appear in more than one section.
+
+**Validates: Requirements 1.1, 1.2, 1.5**
+
+### Property 2: Section Ordering
+
+For any non-empty grouping result, the Inventory section (if present) SHALL be the first element, and all subsequent vendor sections SHALL be sorted in case-insensitive alphabetical order by their label.
+
+**Validates: Requirements 1.3, 1.4**
+
+### Property 3: Empty Section Omission
+
+For any array of visible queue items, the grouped output SHALL contain no sections with zero items. Specifically, if no CARD/GRANITE/DECOM items exist, no Inventory section appears; if no items exist for a given vendor, no section for that vendor appears.
+
+**Validates: Requirements 1.6, 1.7**
+
+### Property 4: Section Header Count Accuracy
+
+For any section in the grouped output, the item count displayed in the section header SHALL equal the actual number of items in that section's items array.
+
+**Validates: Requirements 3.1, 3.2**
+
+### Property 5: Selection Independence from Collapse State
+
+For any combination of selected items and collapse state, the set of selected item IDs SHALL remain unchanged when sections are collapsed or expanded. The selection count SHALL always equal the total number of selected items across all sections, and Select All SHALL toggle selection for all visible items regardless of which sections are collapsed.
+
+**Validates: Requirements 4.2, 4.4, 4.5**
diff --git a/.kiro/specs/queue-collapsible-sections/requirements.md b/.kiro/specs/queue-collapsible-sections/requirements.md
new file mode 100644
index 0000000..7039570
--- /dev/null
+++ b/.kiro/specs/queue-collapsible-sections/requirements.md
@@ -0,0 +1,85 @@
+# Requirements Document
+
+## Introduction
+
+The IvantiTodoQueuePage currently displays all pending queue items in a flat table. This feature introduces collapsible sections that group items using a hybrid layout: an "Inventory" section at the top containing CARD, GRANITE, and DECOM workflow type items, followed by vendor-grouped sections for FP and Archer items. Each section header is clickable to collapse or expand its contents, allowing users to focus on relevant groups while maintaining full cross-section selection and existing page functionality.
+
+## Glossary
+
+- **Queue_Page**: The IvantiTodoQueuePage React component located at `frontend/src/components/pages/IvantiTodoQueuePage.js`
+- **Section**: A collapsible group of queue items sharing a common grouping criterion (either Inventory type or vendor name)
+- **Section_Header**: The clickable row that displays the section label, item count, and collapse/expand toggle
+- **Inventory_Section**: The top-level section containing items with workflow_type of CARD, GRANITE, or DECOM
+- **Vendor_Section**: A section grouping FP and Archer workflow type items by their vendor field
+- **Collapse_State**: A per-section boolean indicating whether the section body is hidden (collapsed) or visible (expanded)
+- **Visible_Items**: Queue items with status "pending" that are displayed in the main list
+
+## Requirements
+
+### Requirement 1: Hybrid Grouping Layout
+
+**User Story:** As a queue operator, I want items grouped into logical sections (Inventory at top, then vendor groups), so that I can quickly locate items by their workflow category.
+
+#### Acceptance Criteria
+
+1. THE Queue_Page SHALL group Visible_Items into an Inventory_Section containing all items where workflow_type is CARD, GRANITE, or DECOM.
+2. THE Queue_Page SHALL group remaining Visible_Items (workflow_type FP or Archer) into Vendor_Sections keyed by the item vendor field.
+3. THE Queue_Page SHALL display the Inventory_Section before all Vendor_Sections.
+4. THE Queue_Page SHALL sort Vendor_Sections alphabetically by vendor name.
+5. WHEN a Visible_Item has no vendor value, THE Queue_Page SHALL place the item in a Vendor_Section labeled "Unknown".
+6. WHEN the Inventory_Section contains zero items, THE Queue_Page SHALL omit the Inventory_Section from the layout.
+7. WHEN a vendor has zero pending items, THE Queue_Page SHALL omit that Vendor_Section from the layout.
+
+### Requirement 2: Collapsible Section Headers
+
+**User Story:** As a queue operator, I want to collapse sections I am not working on, so that I can reduce visual clutter and focus on relevant items.
+
+#### Acceptance Criteria
+
+1. THE Queue_Page SHALL render a Section_Header for each displayed Section.
+2. WHEN a user clicks a Section_Header, THE Queue_Page SHALL toggle the Collapse_State of that Section.
+3. WHILE a Section is in expanded Collapse_State, THE Queue_Page SHALL display all items belonging to that Section.
+4. WHILE a Section is in collapsed Collapse_State, THE Queue_Page SHALL hide all items belonging to that Section.
+5. WHILE a Section is in collapsed Collapse_State, THE Section_Header SHALL remain visible with the section label and item count.
+6. THE Section_Header SHALL display a directional chevron icon indicating the current Collapse_State (down for expanded, right for collapsed).
+7. THE Queue_Page SHALL initialize all Sections in expanded Collapse_State on page load.
+
+### Requirement 3: Section Header Display
+
+**User Story:** As a queue operator, I want section headers to show the group name and item count, so that I can assess workload distribution at a glance.
+
+#### Acceptance Criteria
+
+1. THE Section_Header for the Inventory_Section SHALL display the label "Inventory" with the total count of items in that section.
+2. THE Section_Header for each Vendor_Section SHALL display the vendor name with the total count of items in that section.
+3. THE Section_Header SHALL use a monospace font, uppercase text, and styling consistent with the dark theme tactical intelligence aesthetic.
+4. THE Section_Header SHALL use a distinct accent color for the Inventory_Section (green) and a neutral color for Vendor_Sections.
+5. THE Section_Header SHALL display a bottom border to visually separate the header from section content.
+
+### Requirement 4: Cross-Section Selection Preservation
+
+**User Story:** As a queue operator, I want to select items across multiple sections without losing selections when collapsing sections, so that I can create consolidated Jira tickets from items in different groups.
+
+#### Acceptance Criteria
+
+1. WHILE selection mode is active, THE Queue_Page SHALL allow selecting items from any combination of Sections.
+2. WHEN a Section is collapsed, THE Queue_Page SHALL preserve the selected state of items within that collapsed Section.
+3. WHEN a Section is expanded after being collapsed, THE Queue_Page SHALL display the previously selected items as still selected.
+4. THE selection count indicator SHALL reflect the total count of selected items across all Sections regardless of Collapse_State.
+5. THE Select All checkbox SHALL toggle selection for all Visible_Items across all Sections regardless of Collapse_State.
+6. THE floating action bar SHALL operate on all selected items across all Sections regardless of Collapse_State.
+
+### Requirement 5: Visual and Interaction Consistency
+
+**User Story:** As a queue operator, I want the collapsible sections to match the existing page aesthetic and not break existing functionality, so that the experience remains cohesive.
+
+#### Acceptance Criteria
+
+1. THE Queue_Page SHALL use inline styles consistent with the existing STYLES constant and dark theme tactical intelligence aesthetic.
+2. THE Queue_Page SHALL use lucide-react icons for the collapse/expand chevron indicators.
+3. THE Section_Header SHALL provide a pointer cursor to indicate clickability.
+4. WHEN items are grouped into Sections, THE ticket link badges SHALL continue to display on items that have associated Jira tickets.
+5. WHEN items are grouped into Sections, THE consolidation modal SHALL continue to function with the selected items.
+6. WHEN items are grouped into Sections, THE floating action bar SHALL continue to appear when one or more items are selected.
+7. THE Queue_Page SHALL continue to display the completed items count at the bottom of the page.
+8. IF the queue contains zero Visible_Items, THEN THE Queue_Page SHALL display the existing empty state without any Section_Headers.
diff --git a/.kiro/specs/queue-collapsible-sections/tasks.md b/.kiro/specs/queue-collapsible-sections/tasks.md
new file mode 100644
index 0000000..c628cdd
--- /dev/null
+++ b/.kiro/specs/queue-collapsible-sections/tasks.md
@@ -0,0 +1,112 @@
+# Implementation Plan: Queue Collapsible Sections
+
+## Overview
+
+Add collapsible, grouped sections to the IvantiTodoQueuePage. Items are organized into a hybrid layout: an "Inventory" section (CARD, GRANITE, DECOM workflow types) at the top, followed by vendor-grouped sections for FP and Archer items. Each section has a clickable header that toggles visibility. This is a frontend-only change to a single file with property-based tests for the grouping logic.
+
+## Tasks
+
+- [x] 1. Add section header styles and icon imports
+ - [x] 1.1 Add ChevronDown and ChevronRight to the lucide-react import statement
+ - Add `ChevronDown, ChevronRight` to the existing import from `lucide-react`
+ - _Requirements: 5.2_
+
+ - [x] 1.2 Add section header style entries to the STYLES constant
+ - Add `sectionHeaderInventory`, `sectionHeaderVendor`, and `sectionCount` style objects to the existing `STYLES` constant
+ - Use green accent (`#10B981`) for Inventory header, neutral (`#94A3B8`) for vendor headers
+ - Include `cursor: 'pointer'`, `userSelect: 'none'`, monospace font, uppercase text
+ - _Requirements: 3.3, 3.4, 3.5, 5.1, 5.3_
+
+- [x] 2. Implement grouping computation and collapse state
+ - [x] 2.1 Add the `groupedSections` useMemo hook
+ - Add a `useMemo` that transforms `visibleItems` into an array of section objects `{ key, label, type, items }`
+ - Items with workflow_type CARD, GRANITE, or DECOM go into the Inventory section
+ - Remaining items are grouped by vendor field (null/empty vendor → "Unknown")
+ - Inventory section appears first (if non-empty), vendor sections sorted alphabetically
+ - Sections with zero items are omitted
+ - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7_
+
+ - [x] 2.2 Add collapse state and toggle handler
+ - Add `const [collapsedSections, setCollapsedSections] = useState({})` for collapse tracking
+ - Add `toggleSection` callback that flips the boolean for a given section key
+ - All sections default to expanded (empty object → falsy lookup)
+ - _Requirements: 2.2, 2.7_
+
+- [x] 3. Refactor render to use grouped sections with collapsible headers
+ - [x] 3.1 Replace flat `visibleItems.map(...)` with grouped section rendering
+ - Map over `groupedSections` instead of `visibleItems` directly
+ - For each section, render a clickable Section Header with chevron icon, label, and item count
+ - Conditionally render section body (item rows) only when section is not collapsed
+ - Preserve existing queue item row rendering logic inside each section body
+ - Add `role="button"`, `tabIndex={0}`, `aria-expanded`, `aria-label` to section headers for accessibility
+ - _Requirements: 2.1, 2.3, 2.4, 2.5, 2.6, 3.1, 3.2_
+
+ - [x] 3.2 Verify cross-section selection still works correctly
+ - Ensure Select All checkbox still toggles all `visibleItems` across all sections regardless of collapse state
+ - Ensure selection count reflects total selected items across all sections
+ - Ensure floating action bar operates on all selected items regardless of collapse state
+ - Ensure collapsing a section does not clear or modify `selectedIds`
+ - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
+
+ - [x] 3.3 Verify existing functionality is preserved
+ - Ticket link badges continue to display on items with associated Jira tickets
+ - Consolidation modal continues to function with selected items
+ - Floating action bar appears when items are selected
+ - Completed items count still displays at the bottom
+ - Empty state renders without section headers when no visible items exist
+ - _Requirements: 5.4, 5.5, 5.6, 5.7, 5.8_
+
+- [x] 4. Checkpoint - Ensure frontend builds successfully
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 5. Write property-based tests for grouping logic
+ - [x]* 5.1 Write property test: Grouping Correctness
+ - **Property 1: Grouping Correctness**
+ - Every item with workflow_type CARD, GRANITE, or DECOM appears in the Inventory section; every FP/Archer item appears in the vendor section matching its vendor field (or "Unknown" if null/empty); no item appears in more than one section
+ - Extract the grouping logic into a testable pure function or test it inline
+ - **Validates: Requirements 1.1, 1.2, 1.5**
+
+ - [x]* 5.2 Write property test: Section Ordering
+ - **Property 2: Section Ordering**
+ - Inventory section (if present) is always first; vendor sections are sorted alphabetically by label
+ - **Validates: Requirements 1.3, 1.4**
+
+ - [x]* 5.3 Write property test: Empty Section Omission
+ - **Property 3: Empty Section Omission**
+ - No section in the output has zero items; if no inventory-type items exist, no Inventory section appears
+ - **Validates: Requirements 1.6, 1.7**
+
+ - [x]* 5.4 Write property test: Section Header Count Accuracy
+ - **Property 4: Section Header Count Accuracy**
+ - For every section, the items array length equals the count that would be displayed in the header
+ - **Validates: Requirements 3.1, 3.2**
+
+ - [x]* 5.5 Write property test: Selection Independence from Collapse State
+ - **Property 5: Selection Independence from Collapse State**
+ - Toggling collapse state does not alter the set of selected item IDs; Select All always covers all visible items regardless of collapse
+ - **Validates: Requirements 4.2, 4.4, 4.5**
+
+- [x] 6. Final checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+## Notes
+
+- Tasks marked with `*` are optional and can be skipped for faster MVP
+- This is a frontend-only change to a single file: `frontend/src/components/pages/IvantiTodoQueuePage.js`
+- Property tests should use `fast-check` (already a project dependency) and extract the grouping logic as a pure function for testability
+- The selection logic (`selectedIds`, `toggleSelectAll`, `allVisibleSelected`) operates on `visibleItems` which is independent of collapse state — no changes needed to selection logic
+- The collapse state is purely visual — it controls rendering, not data
+
+## Task Dependency Graph
+
+```json
+{
+ "waves": [
+ { "id": 0, "tasks": ["1.1", "1.2"] },
+ { "id": 1, "tasks": ["2.1", "2.2"] },
+ { "id": 2, "tasks": ["3.1"] },
+ { "id": 3, "tasks": ["3.2", "3.3"] },
+ { "id": 4, "tasks": ["5.1", "5.2", "5.3", "5.4", "5.5"] }
+ ]
+}
+```
diff --git a/.kiro/specs/remediation-plan-history/design.md b/.kiro/specs/remediation-plan-history/design.md
index 988180e..4afc5c2 100644
--- a/.kiro/specs/remediation-plan-history/design.md
+++ b/.kiro/specs/remediation-plan-history/design.md
@@ -4,6 +4,8 @@
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
```
@@ -59,7 +61,21 @@ Adds an append-only audit trail for resolution_date and remediation_plan changes
└─────────────────────────────────────────────────────────────────┘
```
-## Data Model
+## 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
@@ -277,3 +293,492 @@ The `compliance_item_history` table is never referenced by any reporting query.
- 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: