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
This commit is contained in:
Jordan Ramos
2026-06-04 11:27:31 -06:00
parent 8ebd7e4d5e
commit a61d254ff9
54 changed files with 6992 additions and 59 deletions

View File

@@ -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 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
```
@@ -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 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
```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: <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:
```sql
-- Get current values for each targeted metric
SELECT metric_id, resolution_date, remediation_plan
FROM compliance_items
WHERE hostname = $1 AND metric_id = ANY($2) AND status = 'active'
```
```sql
-- Insert history per metric per changed field
INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
```
```sql
-- Update only targeted rows
UPDATE compliance_items
SET resolution_date = $1, remediation_plan = $2
WHERE hostname = $3 AND metric_id = ANY($4) AND status = 'active'
```
**Response:** unchanged (`{ updated: number }`).
#### GET /api/compliance/items/:hostname (extended history response)
The history query now includes `metric_id`:
```sql
SELECT id, hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by, changed_at
FROM compliance_item_history
WHERE hostname = $1
ORDER BY changed_at DESC
LIMIT 10
```
Response shape — history entries now include `metric_id`:
```json
{
"history": [
{
"id": 55,
"metric_id": "2.1.1",
"field_name": "resolution_date",
"old_value": "2026-02-01",
"new_value": "2026-04-15",
"change_reason": "Vendor delay on patch",
"changed_by": "jsmith",
"changed_at": "2026-02-10T11:00:00.000Z"
},
{
"id": 54,
"metric_id": null,
"field_name": "remediation_plan",
"old_value": "Upgrade firmware",
"new_value": "Replace hardware",
"change_reason": null,
"changed_by": "admin",
"changed_at": "2026-02-05T09:00:00.000Z"
}
]
}
```
### Frontend Changes — Per-Metric Metadata Selector
#### New State
```javascript
const [metricSelection, setMetricSelection] = useState([]); // metric_ids for metadata editing
```
This is separate from the existing `selectedMetrics` state (used for notes). The metadata metric selector has its own selection state.
#### UI Layout
The Metric_Selector is placed above the resolution_date and remediation_plan inputs, matching the notes section pattern:
```
ComplianceDetailPanel
├── Header (hostname, IP, device type, team)
├── Section: Failing Metrics
├── Section: Resolved Metrics
├── Section: Remediation Plan & Resolution Date
│ ├── MetricChipSelector (for metadata) ← NEW
│ │ ├── Select All / Deselect All toggle
│ │ └── MetricChip (per active metric, clickable)
│ ├── Resolution Date input (populated from selection)
│ ├── Remediation Plan textarea (populated from selection)
│ ├── Change Reason input
│ └── Save button
├── Section: Change History
│ └── HistoryEntry (with optional MetricChip) ← EXTENDED
├── Section: Notes
│ ├── NoteCard (grouped by group_id)
│ └── Add Note Form (with its own MetricChipSelector)
```
#### MetricChipSelector Behavior (Metadata)
| State | Behavior |
|---|---|
| 1 active metric | Chip is pre-selected and non-removable. No Select All toggle. |
| 2+ active metrics, panel just opened | **All metrics pre-selected** (Select All default). Select All toggle visible. |
| User clicks unselected chip | Chip added to selection |
| User clicks selected chip (2+ selected) | Chip removed from selection |
| User clicks selected chip (only 1 selected, 2+ metrics exist) | No-op — at least one must remain selected |
| Select All clicked | All active metrics selected, toggle label changes to "Deselect All" |
| Deselect All clicked | All metrics deselected except the first (minimum selection invariant) |
**Key difference from notes selector:** The metadata selector defaults to "Select All" on panel open (preserving existing hostname-level behavior), while the notes selector defaults to the first metric only.
#### Per-Metric Field Display Logic
When the metric selection changes, the panel computes what to display in the resolution_date and remediation_plan inputs:
```javascript
function computeSharedValues(metrics, selectedIds) {
const selected = metrics.filter(m => selectedIds.includes(m.metric_id));
if (selected.length === 0) return { resolution_date: '', remediation_plan: '' };
if (selected.length === 1) {
return {
resolution_date: selected[0].resolution_date || '',
remediation_plan: selected[0].remediation_plan || '',
};
}
// Multiple selected — check if values are uniform
const dates = new Set(selected.map(m => m.resolution_date || ''));
const plans = new Set(selected.map(m => m.remediation_plan || ''));
return {
resolution_date: dates.size === 1 ? [...dates][0] : null, // null = "Multiple values"
remediation_plan: plans.size === 1 ? [...plans][0] : null, // null = "Multiple values"
};
}
```
**Display rules:**
| Computed value | Input state |
|---|---|
| Non-null string (including empty) | Input populated with that value |
| `null` (multiple different values) | Input empty, placeholder text: "Multiple values" |
**Save behavior with "Multiple values":**
- If the user leaves an input untouched while it shows "Multiple values" placeholder, that field is NOT included in the PATCH request body — preserving existing per-metric values.
- If the user types a new value into a "Multiple values" field, that value is sent and applied to all selected metrics.
#### Save Flow — Metric Scoping
```javascript
const handleSaveMetadata = async (fields) => {
const body = { ...fields };
if (changeReason.trim()) body.change_reason = changeReason.trim();
// Determine if we need metric scoping
const allSelected = activeMetrics.length > 0 &&
activeMetrics.every(m => metricSelection.includes(m.metric_id));
if (!allSelected) {
// Specific metrics selected — include metric_ids
body.metric_ids = metricSelection;
}
// If all selected, omit metric_ids → hostname-level update (backward compat)
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
// ... error handling, re-fetch ...
};
```
### History Display — Per-Metric Extension
History entries now optionally show which metric the change applied to:
**Display format (extended):**
```
[MetricChip "2.1.1"] [field_name icon] old_value → new_value
username · 2026-01-20 [reason if present]
```
Or for hostname-level changes (metric_id is null):
```
[All metrics] [field_name icon] old_value → new_value
username · 2026-01-20 [reason if present]
```
**Rendering logic:**
```javascript
function HistoryMetricLabel({ metricId, metricMap }) {
if (metricId) {
return <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):**
```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.
**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.

View File

@@ -4,6 +4,8 @@
Historical tracking for resolution dates and remediation plans on compliance items. When a user changes the resolution_date or remediation_plan for a device, the previous value is preserved as an audit trail entry with a timestamp and the identity of the user who made the change. The most recent values remain directly queryable on the compliance_items table so existing VCL reporting queries continue to work without modification.
This spec also covers per-metric scoping of resolution_date and remediation_plan (GitLab issue #19). Previously these fields were edited at the hostname level (one value applied uniformly to all active metrics for a device). The per-metric extension allows analysts to set different resolution dates and remediation plans for individual metrics within the same device, matching the pattern already established by compliance notes.
## Glossary
- **Compliance_Item**: A row in the `compliance_items` table representing a single non-compliant device/metric pair.
@@ -14,6 +16,10 @@ Historical tracking for resolution dates and remediation plans on compliance ite
- **Detail_Panel**: The ComplianceDetailPanel UI component that displays device-level compliance information and allows editing of metadata fields.
- **VCL_Report**: The multi-vertical compliance reporting system that uses resolution_date for burndown forecasts and blocked/in-progress donut charts.
- **Current_Value**: The value stored directly on the compliance_items row, representing the most recent resolution_date or remediation_plan.
- **Metric_Selector**: The UI control in the Detail_Panel that allows the user to choose which metric(s) a remediation plan update applies to. Displays active metrics as selectable options with category-colored chips.
- **Active_Metric**: A compliance item with `status = 'active'` for the selected hostname — a metric currently failing for that device.
- **Metric_Chip**: A small colored badge displaying a metric ID, used throughout the compliance UI to visually identify metrics by category color.
- **Metadata_API**: The `PATCH /api/compliance/items/:hostname/metadata` endpoint that updates resolution_date and remediation_plan on compliance items.
## Requirements
@@ -96,3 +102,101 @@ Historical tracking for resolution dates and remediation plans on compliance ite
2. THE Migration SHALL create the required indexes on the `compliance_item_history` table.
3. THE Migration SHALL be idempotent and safe to run multiple times without error.
4. THE Migration SHALL NOT modify the existing compliance_items table structure.
### Requirement 8: Per-Metric Metadata API
**User Story:** As a compliance analyst, I want to set resolution dates and remediation plans for specific metrics within a device, so that I can track different remediation timelines for different compliance failures on the same hostname.
#### Acceptance Criteria
1. THE Metadata_API SHALL accept an optional `metric_id` field (string) in the request body to scope the update to a single metric for the given hostname.
2. THE Metadata_API SHALL accept an optional `metric_ids` field (array of strings) in the request body to scope the update to multiple specific metrics for the given hostname.
3. WHEN `metric_ids` is provided, THE Metadata_API SHALL update only the compliance_items rows matching the specified hostname AND metric_id values.
4. WHEN `metric_id` is provided (single string), THE Metadata_API SHALL update only the compliance_items row matching the specified hostname AND metric_id.
5. IF both `metric_id` and `metric_ids` are provided, THEN THE Metadata_API SHALL use `metric_ids` and ignore `metric_id`.
6. WHEN neither `metric_id` nor `metric_ids` is provided, THE Metadata_API SHALL update all active compliance_items for the hostname, preserving backward compatibility with the existing hostname-level behavior.
7. IF a provided metric_id does not correspond to an active compliance_item for the hostname, THEN THE Metadata_API SHALL return a 400 error identifying the invalid metric_id.
8. WHEN `metric_ids` is provided, THE Metadata_API SHALL validate that each entry is a non-empty string of 100 characters or fewer.
### Requirement 9: Per-Metric Metric Selector UI
**User Story:** As a compliance analyst, I want a metric selector in the detail panel when editing remediation plans, so that I can choose which metrics a resolution date or remediation plan applies to — matching the pattern used for notes.
#### Acceptance Criteria
1. WHEN the Detail_Panel is open for a hostname with more than one Active_Metric, THE Detail_Panel SHALL display a Metric_Selector above the resolution date and remediation plan inputs.
2. WHEN the Detail_Panel is open for a hostname with exactly one Active_Metric, THE Detail_Panel SHALL pre-select that metric and display the Metric_Selector as a single non-removable selection.
3. THE Metric_Selector SHALL allow the user to select one or more Active_Metrics simultaneously.
4. THE Metric_Selector SHALL display each option using the Metric_Chip component with the metric's category color, so that metrics are visually distinguishable.
5. WHEN the hostname has more than one Active_Metric, THE Metric_Selector SHALL display a "Select All" toggle that selects all Active_Metrics when activated.
6. WHEN all Active_Metrics are already selected, THE "Select All" toggle SHALL change to "Deselect All" and deselect all Active_Metrics when activated.
7. WHEN the Detail_Panel first opens for a hostname with multiple Active_Metrics, THE Metric_Selector SHALL pre-select all Active_Metrics by default, preserving the existing hostname-level editing experience.
### Requirement 10: Per-Metric Field Display
**User Story:** As a compliance analyst, I want to see the current resolution date and remediation plan for the selected metric(s), so that I know what values are already set before making changes.
#### Acceptance Criteria
1. WHEN a single metric is selected in the Metric_Selector, THE Detail_Panel SHALL populate the resolution date and remediation plan inputs with the current values from that specific compliance_item row.
2. WHEN multiple metrics are selected and all share the same resolution_date value, THE Detail_Panel SHALL display that shared value in the resolution date input.
3. WHEN multiple metrics are selected and they have different resolution_date values, THE Detail_Panel SHALL display the resolution date input as empty with placeholder text indicating "Multiple values".
4. WHEN multiple metrics are selected and all share the same remediation_plan value, THE Detail_Panel SHALL display that shared value in the remediation plan input.
5. WHEN multiple metrics are selected and they have different remediation_plan values, THE Detail_Panel SHALL display the remediation plan input as empty with placeholder text indicating "Multiple values".
6. WHEN the user saves with "Multiple values" placeholder visible and the input left empty, THE Detail_Panel SHALL NOT send that field in the PATCH request, preserving existing per-metric values.
### Requirement 11: Per-Metric History Tracking
**User Story:** As a compliance analyst, I want the change history to record which specific metric was changed, so that the audit trail reflects per-metric remediation plan changes accurately.
#### Acceptance Criteria
1. THE compliance_item_history table SHALL include a `metric_id` column (text, nullable) to record which metric the change applies to.
2. WHEN a per-metric update changes a field value, THE History_Service SHALL record the metric_id in the History_Entry.
3. WHEN a hostname-level update (no metric_id specified) changes a field value, THE History_Service SHALL record the metric_id as NULL in the History_Entry, indicating the change applied to all metrics.
4. THE History_Entry SHALL record the old_value and new_value per metric when a per-metric update is performed, reflecting the actual previous value of that specific compliance_item row.
5. WHEN a per-metric update targets multiple metrics with different current values, THE History_Service SHALL insert one History_Entry per metric that actually changed, each with its own old_value.
6. THE Compliance_API SHALL include the metric_id field in history entries returned to the client.
### Requirement 12: Per-Metric History Display
**User Story:** As a compliance analyst, I want the change history section to show which metric each change applied to, so that I can distinguish between hostname-wide changes and metric-specific changes.
#### Acceptance Criteria
1. WHEN a History_Entry has a non-null metric_id, THE Detail_Panel SHALL display the associated Metric_Chip next to the history entry.
2. WHEN a History_Entry has a null metric_id, THE Detail_Panel SHALL display "All metrics" label next to the history entry to indicate a hostname-level change.
3. THE Detail_Panel SHALL continue to display all history entries for the hostname in reverse chronological order, regardless of metric_id.
### Requirement 13: Per-Metric Reporting
**User Story:** As a VCL report consumer, I want burndown forecasts to use per-metric resolution dates, so that the forecast accurately reflects when each individual compliance failure is expected to be resolved.
#### Acceptance Criteria
1. THE VCL_Report SHALL read resolution_date from each individual compliance_items row (per hostname+metric_id pair) for burndown calculations, rather than using a single hostname-level value.
2. THE VCL_Report SHALL count each compliance_item row with a null resolution_date as a separate blocker in the donut chart.
3. THE VCL_Report SHALL bucket each compliance_item row by its own resolution_date month for the burndown forecast chart.
4. WHEN deduplicating by hostname for aggregated views, THE VCL_Report SHALL use the latest (MAX) resolution_date among all active metrics for that hostname.
### Requirement 14: Per-Metric History Migration
**User Story:** As a developer, I want the history table extended with a metric_id column via a migration script, so that per-metric history tracking can be deployed to existing environments.
#### Acceptance Criteria
1. THE Migration SHALL add a nullable `metric_id` column (text) to the `compliance_item_history` table.
2. THE Migration SHALL create an index on (hostname, metric_id) for efficient per-metric history lookups.
3. THE Migration SHALL be idempotent and safe to run multiple times without error.
4. THE Migration SHALL NOT alter or backfill existing history rows — pre-existing entries retain a NULL metric_id indicating they were hostname-level changes.
### Requirement 15: Backward Compatibility
**User Story:** As an existing user of the bulk upload and API workflows, I want hostname-level updates to continue working without modification, so that existing integrations and scripts are not broken by the per-metric change.
#### Acceptance Criteria
1. WHEN the Metadata_API receives a request without `metric_id` or `metric_ids`, THE Metadata_API SHALL update all active compliance_items for the hostname, matching the pre-existing behavior.
2. WHEN the bulk update commit endpoint processes a row, THE Metadata_API SHALL continue to apply resolution_date and remediation_plan to all active metrics for that hostname.
3. THE Detail_Panel SHALL default to "Select All" metrics when first opened, so that saving without changing the metric selection produces the same hostname-level update behavior as before.
4. WHEN all metrics are selected and the user saves, THE Metadata_API SHALL NOT include metric_ids in the request body, triggering the hostname-level update path for backward compatibility.

View File

@@ -1,51 +1,85 @@
# Tasks — Remediation Plan History
# Implementation Plan: Remediation Plan History — Per-Metric Extension
## Task 1: Create migration for compliance_item_history table [Requirement 2, 7]
- [x] Create `backend/migrations/add_compliance_item_history.js` with the schema from the design doc
- [~] Table: `compliance_item_history` (id, hostname, field_name, old_value, new_value, change_reason, changed_by, changed_at)
- [~] Add CHECK constraint on field_name: IN ('resolution_date', 'remediation_plan')
- [~] Add index on (hostname, field_name)
- [~] Add index on (changed_at)
- [~] Register in `migrations/run-all.js`
- [~] Run migration and verify table exists
## Overview
## Task 2: Modify PATCH /items/:hostname/metadata to record history [Requirement 1, 6]
- [~] In `backend/routes/compliance.js`, locate the PATCH metadata handler
- [~] Accept new optional `change_reason` field (max 500 chars, validated)
- [~] Before updating compliance_items, SELECT current resolution_date and remediation_plan for the hostname
- [~] For each field where old !== new, INSERT into compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
- [~] Skip history insert if old === new (no-op changes)
- [~] Wrap history insert + item update in a transaction
- [~] Handle NULL → value and value → NULL transitions
- [~] Add audit log entry with old/new values
- [~] Verify existing response shape is preserved
Extends the existing remediation plan history system to support per-metric scoping of resolution_date and remediation_plan. The original hostname-level implementation (Tasks 16) is already complete. Tasks 712 add metric_id targeting to the PATCH endpoint, a metric selector UI in the detail panel, per-metric history tracking, and verification that reporting queries work correctly with per-metric resolution dates. This addresses GitLab issue #19.
## Task 3: Extend GET /items/:hostname to return history [Requirement 4]
- [~] In the existing `/items/:hostname` handler, add a 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`
- [~] Add `history` array to the response object
- [~] If query fails, return empty array (graceful degradation) and log error
- [~] Verify response includes history alongside existing metrics and notes
## Tasks
## Task 4: Modify bulk update commit to track history [Requirement 6]
- [~] In the bulk update flow (POST /vcl/bulk-commit), before updating each hostname's resolution_date or remediation_plan, query current values
- [~] For each changed field, INSERT into compliance_item_history with changed_by = req.user.username
- [~] Skip if value is unchanged
- [~] No change_reason for bulk updates (set to NULL)
- [x] 1. Create migration for compliance_item_history table
- [x] 1.1 Create `backend/migrations/add_compliance_item_history.js` with schema, CHECK constraint, indexes, register in run-all.js, and verify table exists
- _Requirements: 2, 7_
## Task 5: Add change_reason input and history section to ComplianceDetailPanel [Requirement 5]
- [~] Add `changeReason` state and a single-line text input between the Save button and Notes section
- [~] Pass `change_reason` in the PATCH request body when saving
- [~] Clear changeReason after successful save
- [~] Add "Change History" section below the remediation plan area
- [~] Fetch history from the GET /items/:hostname response
- [~] Display entries: field icon, old → new, username, date, reason (if present)
- [~] Resolution dates formatted as YYYY-MM-DD, NULL shown as "—"
- [~] Remediation plan values truncated to 60 chars with title tooltip
- [~] Show "No changes recorded" when history is empty
- [~] Run `npm run build` after changes
- [x] 2. Modify PATCH /items/:hostname/metadata to record history
- [x] 2.1 Accept change_reason, SELECT current values, INSERT history for changed fields, wrap in transaction, handle NULL transitions, add audit log
- _Requirements: 1, 6_
## Task 6: Verify VCL burndown is unaffected [Requirement 3]
- [~] Confirm burndown query in vclMultiVertical.js reads from compliance_items.resolution_date only
- [~] Confirm donut query uses MAX(resolution_date) grouped by hostname
- [~] Set a resolution date, change it multiple times, verify device appears once in burndown
- [~] No code changes expected — verification only
- [x] 3. Extend GET /items/:hostname to return history
- [x] 3.1 Add history query, include history array in response, graceful degradation on failure
- _Requirements: 4_
- [x] 4. Modify bulk update commit to track history
- [x] 4.1 Query current values before update, INSERT history for changed fields, skip unchanged, NULL change_reason
- _Requirements: 6_
- [x] 5. Add change_reason input and history section to ComplianceDetailPanel
- [x] 5.1 Add changeReason state and input, pass in PATCH body, display Change History section with formatted entries, run npm run build
- _Requirements: 5_
- [x] 6. Verify VCL burndown is unaffected
- [x] 6.1 Confirm burndown and donut queries read from compliance_items.resolution_date only, no code changes
- _Requirements: 3_
- [x] 7. Create migration to add metric_id column to compliance_item_history
- [x] 7.1 Create `backend/migrations/add_compliance_history_metric_id.js` that adds nullable `metric_id TEXT` column (idempotent check if column exists), creates index on (hostname, metric_id), register in run-all.js, run migration and verify column exists, verify existing rows retain NULL
- _Requirements: 14_
- [x] 8. Extend PATCH /items/:hostname/metadata to support per-metric scoping
- [x] 8.1 Accept optional `metric_id` (string) and `metric_ids` (array) in request body; if both provided use metric_ids; validate non-empty and max 100 chars; validate each corresponds to active compliance_item
- _Requirements: 8_
- [x] 8.2 When metric_ids provided: SELECT current values per targeted metric, INSERT history with metric_id per changed field, UPDATE only matching rows
- _Requirements: 8, 11_
- [x] 8.3 When neither metric_id nor metric_ids provided: preserve hostname-level behavior with NULL metric_id in history entries
- _Requirements: 15_
- [x] 9. Extend GET /items/:hostname to include metric_id in history entries
- [x] 9.1 Update history query to SELECT metric_id column, include metric_id in each history entry in response, verify NULL entries returned correctly
- _Requirements: 4, 11_
- [x] 10. Add MetricChipSelector for metadata editing in ComplianceDetailPanel
- [x] 10.1 Add metricSelection state separate from notes selector, default all active metrics selected on panel open, render MetricChipSelector above resolution_date/remediation_plan inputs with category-colored chips and Select All/Deselect All toggle
- _Requirements: 9_
- [x] 10.2 Implement computeSharedValues: display shared value when all selected metrics agree, show "Multiple values" placeholder when they differ, omit unchanged fields from PATCH
- _Requirements: 10_
- [x] 10.3 When all metrics selected omit metric_ids from request body (backward compat), when subset selected include metric_ids, run npm run build
- _Requirements: 15_
- [x] 11. Update history display to show per-metric labels
- [x] 11.1 When history entry has non-null metric_id display MetricChip, when null display "All metrics" label, build metricMap from metrics array, verify existing entries display correctly, run npm run build
- _Requirements: 12_
- [x] 12. Verify per-metric burndown reporting works correctly
- [x] 12.1 Confirm burndown forecast reads resolution_date per compliance_items row, donut uses per-row presence, aggregated view uses MAX(resolution_date), no code changes expected
- _Requirements: 13_
## Notes
- Tasks 16 are already implemented (hostname-level history tracking is live in production)
- Tasks 712 implement the per-metric extension from GitLab issue #19
- Task 7 must run first as it adds the metric_id column needed by all subsequent tasks
- Tasks 10 and 11 can run in parallel after Task 9 completes
- Task 12 is verification-only and depends on both frontend tasks completing
## Task Dependency Graph
```json
{
"waves": [
{ "id": 0, "tasks": ["7.1"] },
{ "id": 1, "tasks": ["8.1", "8.2", "8.3"] },
{ "id": 2, "tasks": ["9.1"] },
{ "id": 3, "tasks": ["10.1", "10.2", "10.3", "11.1"] },
{ "id": 4, "tasks": ["12.1"] }
]
}
```