Files
cve-dashboard/.kiro/specs/compliance-multi-metric-notes/design.md

14 KiB
Raw Permalink Blame History

Design Document: Multi-Metric Notes for Compliance Detail Panel

Overview

This feature extends the compliance notes system so that a single note can be associated with multiple metrics in one action. Today, the ComplianceDetailPanel uses a single-select <select> dropdown to pick one metric before adding a note. When a remediation action covers several metrics on the same device, the analyst must repeat the note for each metric individually.

The change touches three layers:

  1. Database — add a group_id column to compliance_notes so notes created together can be identified as a batch.
  2. API — extend POST /api/compliance/notes to accept metric_ids (array) alongside the existing metric_id (string), inserting one row per metric inside a transaction.
  3. Frontend — replace the single-select dropdown with a multi-select chip-based selector, add Select All / Deselect All, and group notes by group_id in the display.

Backward compatibility is preserved: the existing metric_id field continues to work, and notes created before this feature (which lack a group_id) render exactly as they do today.

Architecture

The feature follows the existing compliance module architecture. No new files or route modules are introduced — changes are scoped to the existing compliance.js route file and ComplianceDetailPanel.js component.

sequenceDiagram
    participant User
    participant DetailPanel as ComplianceDetailPanel
    participant API as POST /api/compliance/notes
    participant DB as SQLite (compliance_notes)

    User->>DetailPanel: Select multiple metrics via chip selector
    User->>DetailPanel: Type note text, click Send
    DetailPanel->>API: POST { hostname, metric_ids: [...], note }
    API->>API: Validate inputs (note text, metric IDs)
    API->>API: Generate group_id (UUID)
    API->>DB: BEGIN TRANSACTION
    loop For each metric_id in metric_ids
        API->>DB: INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at)
    end
    API->>DB: COMMIT
    API->>DetailPanel: 201 { notes: [...created rows] }
    DetailPanel->>DetailPanel: Group notes by group_id, refresh display

Components and Interfaces

Backend

Modified: POST /api/compliance/notes

Request body accepts either format:

// New multi-metric format
{ hostname: "SERVER01", metric_ids: ["2.1.1", "2.3.2", "4.1.1"], note: "Vendor ticket VT-1234 opened" }

// Legacy single-metric format (still supported)
{ hostname: "SERVER01", metric_id: "2.1.1", note: "Vendor ticket VT-1234 opened" }

Precedence: if both metric_id and metric_ids are present, metric_ids wins.

Validation rules:

  • hostname — required, string, 1300 chars, matches /^[a-zA-Z0-9._-]+$/ (unchanged)
  • metric_ids — array of strings, each non-empty and ≤50 chars, at least one entry
  • note — required, non-empty after trimming, max 1000 chars (unchanged)

On success, the endpoint returns all created rows (with username joined from users) so the frontend can update without a separate fetch.

New: Migration script backend/migrations/add_compliance_notes_group_id.js

Adds the group_id column and backfills existing rows:

ALTER TABLE compliance_notes ADD COLUMN group_id TEXT;
CREATE INDEX idx_compliance_notes_group ON compliance_notes(group_id);
-- Backfill: each existing row gets its own unique group_id
UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL;

The backfill ensures every row has a group_id, so the frontend grouping logic works uniformly without null checks.

Frontend

Modified: ComplianceDetailPanel.js

The notes section is updated with three changes:

  1. Multi-select metric selector — replaces the <select> dropdown with a chip-based toggle list. Each active metric is rendered as a clickable MetricChip. Selected chips get a highlighted border/background. A "Select All" / "Deselect All" toggle appears when there are 2+ active metrics.

  2. Submission logichandleAddNote sends metric_ids (array of selected metric IDs) instead of metric_id (single string). The submit button is disabled when no metrics are selected or note text is empty.

  3. Note display grouping — notes are grouped by group_id before rendering. Notes sharing a group_id are displayed as a single card with multiple MetricChip badges. Notes without a group_id (pre-migration legacy) render as individual entries, same as today.

Component structure:

ComplianceDetailPanel
├── Header (hostname, IP, device type, team)
├── Section: Failing Metrics
│   └── MetricRow (per active metric)
├── Section: Resolved Metrics
│   └── MetricRow (per resolved metric)
├── Section: History
│   └── MetricChip + seen count (per active metric)
└── Section: Notes
    ├── NoteCard (per group_id group, shows multiple MetricChips if multi-metric)
    └── Add Note Form
        ├── MetricChipSelector (multi-select chip toggles)
        │   ├── MetricChip (per active metric, clickable)
        │   └── Select All / Deselect All toggle
        ├── Textarea (note text)
        └── Send button (disabled when no metrics selected or text empty)

MetricChipSelector behavior:

State Behavior
1 active metric Chip is pre-selected and non-removable. No Select All toggle.
2+ active metrics, panel just opened First metric pre-selected. 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 (to maintain minimum selection)

Design rationale — minimum selection of 1: The submit button is disabled when no metrics are selected (Requirement 3.4). Rather than allowing the user to reach an empty state and see a disabled button, "Deselect All" keeps the first metric selected. This matches the current UX where a metric is always selected.

Data Models

compliance_notes table (modified)

Column Type Description
id INTEGER PK Auto-increment row ID
hostname TEXT NOT NULL Device hostname
metric_id TEXT NOT NULL Compliance metric ID
note TEXT NOT NULL Note text (max 1000 chars)
group_id TEXT Batch identifier — rows from the same submission share this value
created_by INTEGER FK User ID of the note author
created_at DATETIME Timestamp of creation

The group_id is a UUID v4 string generated server-side via crypto.randomUUID(). Single-metric submissions also receive a group_id so the frontend grouping logic is uniform.

Index: idx_compliance_notes_group ON compliance_notes(group_id) — supports the frontend's grouping query.

API Response Shape

POST /api/compliance/notes response (201):

{
  "notes": [
    {
      "id": 42,
      "hostname": "SERVER01",
      "metric_id": "2.1.1",
      "note": "Vendor ticket VT-1234 opened",
      "group_id": "a1b2c3d4-...",
      "created_at": "2025-01-15 14:30:00",
      "created_by": "jsmith"
    },
    {
      "id": 43,
      "hostname": "SERVER01",
      "metric_id": "2.3.2",
      "note": "Vendor ticket VT-1234 opened",
      "group_id": "a1b2c3d4-...",
      "created_at": "2025-01-15 14:30:00",
      "created_by": "jsmith"
    }
  ]
}

GET /api/compliance/items/:hostname response — the existing notes array now includes group_id:

{
  "notes": [
    { "id": 43, "metric_id": "2.3.2", "note": "...", "group_id": "a1b2c3d4-...", "created_at": "...", "created_by": "jsmith" },
    { "id": 42, "metric_id": "2.1.1", "note": "...", "group_id": "a1b2c3d4-...", "created_at": "...", "created_by": "jsmith" },
    { "id": 10, "metric_id": "2.1.1", "note": "...", "group_id": "legacy-10", "created_at": "...", "created_by": "admin" }
  ]
}

The frontend groups consecutive notes by group_id to render multi-metric notes as a single card.

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: Select All / Deselect All round-trip

For any set of active metrics with size > 1, clicking "Select All" should result in all metrics being selected, and then clicking "Deselect All" should result in only the first metric remaining selected (minimum selection invariant).

Validates: Requirements 2.1, 2.2

Property 2: Toggle label reflects selection state

For any set of active metrics, if the user manually selects every metric one by one, the toggle label should read "Deselect All" — the label is a pure function of whether all metrics are selected, regardless of how that state was reached.

Validates: Requirements 2.3

Property 3: Multi-metric submission creates correct rows with shared group_id

For any valid hostname, non-empty note text, and non-empty array of valid metric IDs, submitting a note should create exactly N rows in compliance_notes (where N = length of the metric IDs array), all sharing the same note text, created_by user, created_at timestamp, and group_id value.

Validates: Requirements 3.1, 3.2, 5.3, 5.7, 6.1

Property 4: Whitespace-only notes are rejected

For any string composed entirely of whitespace characters (spaces, tabs, newlines, or combinations thereof), the Notes API should reject the submission with a 400 error and create zero rows in the database.

Validates: Requirements 3.3

Property 5: Atomic validation — invalid metric IDs reject the entire batch

For any array of metric IDs where at least one entry is invalid (empty string, exceeds 50 characters, or non-string), the Notes API should reject the entire request with a 400 error and insert zero rows, even if all other entries are valid.

Validates: Requirements 5.2, 5.6

Property 6: Note grouping display

For any set of notes where multiple notes share the same group_id, the Detail Panel should render them as a single note entry displaying all associated Metric Chips, rather than as separate entries.

Validates: Requirements 4.1, 4.2, 6.4

Property 7: Reverse chronological note ordering

For any set of notes with varying created_at timestamps and group sizes, the Detail Panel should display note groups in reverse chronological order (newest created_at first), regardless of how many metrics each group covers.

Validates: Requirements 4.3

Error Handling

Backend

Scenario Response Behavior
Empty or whitespace-only note text 400 { error: "Note cannot be empty" } No rows inserted
metric_ids is empty array 400 { error: "At least one metric ID is required" } No rows inserted
Any metric ID in array is empty or >50 chars 400 { error: "Invalid metric_id at index N" } No rows inserted (atomic rejection)
metric_ids is not an array (when provided) 400 { error: "metric_ids must be an array" } Falls back to checking metric_id
Neither metric_id nor metric_ids provided 400 { error: "metric_id or metric_ids is required" } No rows inserted
Database error during transaction 500 { error: "Failed to save note" } Transaction rolled back, no partial inserts
Invalid hostname format 400 { error: "Invalid hostname format" } No rows inserted (unchanged)

Transaction safety: all inserts for a multi-metric note happen inside BEGIN TRANSACTION / COMMIT. If any insert fails, the transaction is rolled back and no rows are persisted.

Frontend

Scenario Behavior
API returns 400 validation error Display error message below the note input (existing noteError state)
API returns 500 server error Display error message below the note input
Network failure Display "Failed to save note" error
No metrics selected Submit button is disabled, no API call made
Successful submission Clear note text, refresh notes list, retain metric selection

Testing Strategy

Unit Tests (example-based)

  • Backend:

    • Legacy metric_id field still creates a single note row (backward compatibility)
    • Both metric_id and metric_ids provided — metric_ids takes precedence
    • Single active metric pre-selects and is non-removable
    • Response shape includes all created rows with group_id and username
  • Frontend:

    • MetricChipSelector renders correct number of chips for given active metrics
    • Clicking a chip toggles its selection state
    • Submit button disabled when note text is empty or no metrics selected
    • Notes without group_id (legacy) render as individual entries
    • Single active metric auto-selects and hides Select All toggle

Property-Based Tests

Property-based tests use fast-check (JavaScript PBT library) with a minimum of 100 iterations per property.

Each property test is tagged with a comment referencing the design property:

  • Feature: compliance-multi-metric-notes, Property 3: Multi-metric submission creates correct rows with shared group_id
  • Feature: compliance-multi-metric-notes, Property 4: Whitespace-only notes are rejected
  • Feature: compliance-multi-metric-notes, Property 5: Atomic validation — invalid metric IDs reject the entire batch

Backend properties (3, 4, 5) are tested against the route handler using a test SQLite database. Frontend properties (1, 2, 6, 7) are tested against the component rendering/grouping logic using React Testing Library with generated inputs.

Integration Tests

  • End-to-end flow: open detail panel → select multiple metrics → submit note → verify grouped display
  • Migration script: verify group_id column exists and legacy rows are backfilled
  • Backward compatibility: existing GET /items/:hostname response includes group_id field on notes