Files
cve-dashboard/.kiro/specs/compliance-chart-replacements/design.md

314 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design Document: Compliance Chart Replacements
## Overview
This feature replaces two underperforming charts in the `ComplianceChartsPanel` with more actionable visualizations:
| Slot | Old Chart | New Chart |
|------|-----------|-----------|
| Chart 4 | MTTR by Team (horizontal bar) | **Aging Findings Distribution** — stacked vertical bar showing active findings bucketed by `seen_count` with per-team color segments |
| Chart 5 | Most Persistent Findings (horizontal bar) | **Net Change Waterfall** — waterfall bar chart showing per-cycle net movement (start → +new → +recurring → resolved → end) |
Both new charts derive data from columns already present in the SQLite schema (`compliance_items.seen_count`, `compliance_uploads.new_count / recurring_count / resolved_count`). No database migrations are required. The four other charts (Active Findings Over Time, Change per Cycle, Team Compliance Health, Archer Exception Pipeline) remain unchanged.
The backend reuses the existing route paths (`/mttr` → aging, `/top-recurring` → waterfall) so no new API routes are introduced. The frontend replaces the two component functions and their corresponding state variables while preserving the shared infrastructure (`ChartCard`, `DarkTooltip`, `NoData`, `TEAM_COLORS`, `fmtDate`, style tokens).
## Architecture
The change is scoped to two files and follows the existing vertical-slice pattern:
```mermaid
graph LR
subgraph Backend ["Express Backend"]
R1["/compliance/mttr<br/>(was MTTR → now Aging)"]
R2["/compliance/top-recurring<br/>(was Persistent → now Waterfall)"]
DB[(SQLite<br/>compliance_items<br/>compliance_uploads)]
R1 -->|"SELECT … GROUP BY bucket, team"| DB
R2 -->|"SELECT … ORDER BY report_date"| DB
end
subgraph Frontend ["React Frontend"]
Panel["ComplianceChartsPanel"]
AC["AgingChart"]
WC["WaterfallChart"]
Panel --> AC
Panel --> WC
end
R1 -->|JSON| AC
R2 -->|JSON| WC
```
### Key Architectural Decisions
1. **Route path reuse** — The old `/mttr` and `/top-recurring` paths are replaced in-place rather than creating new paths. This avoids route proliferation and means no proxy/CORS changes are needed.
2. **No new dependencies** — Both charts are built with the existing Recharts `BarChart` / `Bar` components. The waterfall effect is achieved using a transparent "base" `Bar` with `fill="transparent"` to offset the visible segments, which is a standard Recharts stacking technique.
3. **No database migration** — The aging chart queries `compliance_items.seen_count` (already populated by the upload pipeline). The waterfall chart reads `new_count`, `recurring_count`, `resolved_count` from `compliance_uploads` (already written by `persistUpload`). Starting/ending counts are computed in the route handler via a running accumulator.
4. **Shared component reuse** — Both new chart components use the existing `ChartCard`, `DarkTooltip`, `NoData`, `TEAM_COLORS`, `AXIS_STYLE`, `GRID_STYLE`, and `LEGEND_STYLE` tokens already defined in `ComplianceChartsPanel.js`.
## Components and Interfaces
### Backend: Aging Endpoint (replaces `/mttr`)
**Route:** `GET /compliance/mttr`
**SQL Query:**
```sql
SELECT
CASE
WHEN seen_count = 1 THEN '1 cycle'
WHEN seen_count BETWEEN 2 AND 3 THEN '23 cycles'
WHEN seen_count BETWEEN 4 AND 6 THEN '46 cycles'
ELSE '7+ cycles'
END AS bucket,
team,
COUNT(*) AS count
FROM compliance_items
WHERE status = 'active'
GROUP BY bucket, team
ORDER BY
CASE bucket
WHEN '1 cycle' THEN 1
WHEN '23 cycles' THEN 2
WHEN '46 cycles' THEN 3
WHEN '7+ cycles' THEN 4
END
```
**Response shape:**
```json
{
"aging": [
{ "bucket": "1 cycle", "total": 12, "STEAM": 5, "ACCESS-ENG": 3, "ACCESS-OPS": 2, "INTELDEV": 2 },
{ "bucket": "23 cycles", "total": 8, "STEAM": 2, "ACCESS-ENG": 4, "ACCESS-OPS": 1, "INTELDEV": 1 },
{ "bucket": "46 cycles", "total": 3, "STEAM": 1, "ACCESS-ENG": 0, "ACCESS-OPS": 2, "INTELDEV": 0 },
{ "bucket": "7+ cycles", "total": 2, "STEAM": 0, "ACCESS-ENG": 1, "ACCESS-OPS": 0, "INTELDEV": 1 }
]
}
```
**Handler logic:**
1. Query active items grouped by bucket CASE expression and team.
2. Pivot the flat rows into one object per bucket with per-team counts and a total.
3. Return the array sorted by bucket order (ascending age).
### Backend: Waterfall Endpoint (replaces `/top-recurring`)
**Route:** `GET /compliance/top-recurring`
**SQL Query:**
```sql
SELECT id, report_date,
COALESCE(new_count, 0) AS new_count,
COALESCE(recurring_count, 0) AS recurring_count,
COALESCE(resolved_count, 0) AS resolved_count
FROM compliance_uploads
ORDER BY report_date ASC
```
**Response shape:**
```json
{
"waterfall": [
{
"date": "2026-04-13",
"start": 0,
"new_count": 15,
"recurring_count": 0,
"resolved_count": 0,
"end": 15
},
{
"date": "2026-04-20",
"start": 15,
"new_count": 3,
"recurring_count": 12,
"resolved_count": 5,
"end": 25
}
]
}
```
**Handler logic:**
1. Fetch all uploads ordered by `report_date ASC`.
2. Iterate with a running accumulator: `start` for cycle N = `end` of cycle N1 (first cycle starts at 0).
3. Compute `end = start + new_count + recurring_count - resolved_count`.
4. Return the array.
### Frontend: `AgingChart` Component
Replaces `MttrChart`. Renders a vertical stacked `BarChart`:
- **X-axis:** bucket labels (`1 cycle`, `23 cycles`, `46 cycles`, `7+ cycles`)
- **Y-axis:** finding count
- **Stacked bars:** one `<Bar>` per team key from `TEAM_COLORS`, using `stackId="aging"`
- **Tooltip:** `DarkTooltip` showing bucket label, per-team counts, and total
- **Empty state:** `<NoData />` when `aging` array is empty
- **Wrapper:** `<ChartCard title="Aging Findings Distribution" subtitle="Active findings by age bucket — stacked by team" />`
### Frontend: `WaterfallChart` Component
Replaces `RecurringChart`. Renders a vertical `BarChart` with stacked transparent base:
- **X-axis:** cycle dates formatted via `fmtDate`
- **Y-axis:** finding count
- **Bars (stacked):**
1. `<Bar dataKey="start" stackId="w" fill="transparent" />` — invisible base
2. `<Bar dataKey="new_count" stackId="w" fill="#EF4444" />` — red new findings
3. `<Bar dataKey="recurring_count" stackId="w" fill="#F59E0B" />` — amber recurring
- **Separate bar:** `<Bar dataKey="resolved_count" fill="#10B981" />` rendered as a standalone (not stacked) to visually represent the downward resolution. Since Recharts doesn't natively support negative waterfall segments, the resolved bar is rendered separately alongside the stacked group, matching the existing `DeltaChart` pattern already used in Chart 2.
- **Tooltip:** Custom `WaterfallTooltip` (extends `DarkTooltip` pattern) showing date, start, new, recurring, resolved, and end values.
- **Empty state:** `<NoData />` when `waterfall` array is empty
- **Wrapper:** `<ChartCard title="Net Change Waterfall" subtitle="Per-cycle net movement: start → +new → +recurring → resolved → end" />`
### Frontend: State & Fetch Changes in `ComplianceChartsPanel`
| Old | New |
|-----|-----|
| `const [mttr, setMttr] = useState([])` | `const [aging, setAging] = useState([])` |
| `const [recurring, setRecurring] = useState([])` | `const [waterfall, setWaterfall] = useState([])` |
| `fetch(…/compliance/mttr)``setMttr(d.mttr)` | `fetch(…/compliance/mttr)``setAging(d.aging)` |
| `fetch(…/compliance/top-recurring)``setRecurring(d.items)` | `fetch(…/compliance/top-recurring)``setWaterfall(d.waterfall)` |
The `Promise.all` pattern and error handling remain identical. The other two fetches (`/trends`, `/archer-tickets/status-trend`) are untouched.
### Code Removal
- Delete `MttrChart` function component
- Delete `RecurringChart` function component
- Remove `mttr` and `recurring` state variables and their setter calls
- Remove old `/mttr` handler SQL and logic in `compliance.js`
- Remove old `/top-recurring` handler SQL and logic in `compliance.js`
## Data Models
No schema changes are required. The feature reads from existing columns:
### `compliance_items` (read by Aging endpoint)
| Column | Type | Usage |
|--------|------|-------|
| `status` | TEXT (`'active'` / `'resolved'`) | Filter to active items only |
| `seen_count` | INTEGER (default 1) | Bucketed into age groups via CASE expression |
| `team` | TEXT | Group-by for per-team stacked segments |
### `compliance_uploads` (read by Waterfall endpoint)
| Column | Type | Usage |
|--------|------|-------|
| `report_date` | TEXT (YYYY-MM-DD) | X-axis label, ordering |
| `new_count` | INTEGER (default 0) | New findings in this cycle |
| `recurring_count` | INTEGER (default 0) | Recurring findings in this cycle |
| `resolved_count` | INTEGER (default 0) | Resolved findings in this cycle |
### Derived Waterfall Fields (computed in handler, not stored)
| Field | Computation |
|-------|-------------|
| `start` | Previous cycle's `end` (0 for first cycle) |
| `end` | `start + new_count + recurring_count - resolved_count` |
## 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: Aging bucketing correctness and conservation
*For any* array of active compliance items with arbitrary `seen_count` values (≥ 1) and arbitrary team assignments, applying the aging bucketing logic SHALL:
- assign every item to exactly one of the four buckets (`1 cycle` for seen_count = 1, `23 cycles` for 2 ≤ seen_count ≤ 3, `46 cycles` for 4 ≤ seen_count ≤ 6, `7+ cycles` for seen_count ≥ 7),
- produce per-team counts within each bucket that sum to the bucket's total count, and
- produce bucket totals that sum to the total number of input items.
**Validates: Requirements 1.1, 1.2**
### Property 2: Waterfall chain linkage and arithmetic invariant
*For any* ordered sequence of upload records with non-negative `new_count`, `recurring_count`, and `resolved_count` values, computing the waterfall SHALL produce a result where:
- the first row has `start = 0`,
- for every subsequent row N, `start[N] = end[N-1]` (chain linkage), and
- for every row, `end = start + new_count + recurring_count - resolved_count` (arithmetic invariant).
**Validates: Requirements 3.1, 3.2, 3.3**
## Error Handling
### Backend
| Scenario | Behavior |
|----------|----------|
| No active findings (aging endpoint) | Return `{ aging: [] }` with HTTP 200 |
| No uploads (waterfall endpoint) | Return `{ waterfall: [] }` with HTTP 200 |
| SQLite query error (either endpoint) | Log error to console, return HTTP 500 with `{ error: "Database error" }` |
| `seen_count` is NULL or missing | `COALESCE(seen_count, 1)` in the CASE expression treats NULL as 1 cycle |
| `new_count` / `recurring_count` / `resolved_count` is NULL | `COALESCE(..., 0)` in the SELECT ensures zero-default (already present in the uploads table pattern) |
### Frontend
| Scenario | Behavior |
|----------|----------|
| Aging fetch fails (network error or non-200) | `aging` state remains `[]`, `AgingChart` renders `<NoData />` |
| Waterfall fetch fails (network error or non-200) | `waterfall` state remains `[]`, `WaterfallChart` renders `<NoData />` |
| One fetch fails, others succeed | Unaffected charts render normally — each fetch result is handled independently in the existing `try/catch` pattern |
| API returns unexpected shape | Component guards (`data.length === 0`) trigger `<NoData />` gracefully |
## Testing Strategy
### Property-Based Tests (fast-check)
The project backend already uses Jest. Property-based tests will use [fast-check](https://github.com/dubzzz/fast-check) (already available in the Node ecosystem, zero-config with Jest).
Each property test runs a minimum of **100 iterations** with randomly generated inputs.
**Test 1: Aging bucketing correctness and conservation**
- **Tag:** `Feature: compliance-chart-replacements, Property 1: Aging bucketing correctness and conservation`
- **Generator:** Array of objects `{ seen_count: fc.integer({ min: 1, max: 200 }), team: fc.constantFrom('STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV') }`
- **Assertion:** Extract the pure bucketing/pivoting function from the route handler. For each generated array, verify:
1. Every item maps to exactly one of the four bucket labels
2. Per-team counts in each bucket sum to the bucket total
3. All bucket totals sum to the input array length
**Test 2: Waterfall chain linkage and arithmetic invariant**
- **Tag:** `Feature: compliance-chart-replacements, Property 2: Waterfall chain linkage and arithmetic invariant`
- **Generator:** Array of objects `{ new_count: fc.nat({ max: 50 }), recurring_count: fc.nat({ max: 50 }), resolved_count: fc.nat({ max: 50 }), report_date: fc.date().map(d => d.toISOString().slice(0, 10)) }`
- **Assertion:** Extract the pure waterfall computation function. For each generated sequence, verify:
1. `result[0].start === 0`
2. For all i > 0: `result[i].start === result[i-1].end`
3. For all i: `result[i].end === result[i].start + result[i].new_count + result[i].recurring_count - result[i].resolved_count`
### Unit Tests (Jest, example-based)
| Test | Validates |
|------|-----------|
| Aging endpoint returns correct JSON shape with known seed data | Req 1.3 |
| Aging endpoint returns empty array when no active items exist | Req 1.4 |
| Aging endpoint returns 500 on DB error | Req 1.5 |
| Waterfall endpoint returns empty array when no uploads exist | Req 3.5 |
| Waterfall endpoint returns 500 on DB error | Req 3.6 |
| `AgingChart` renders `<NoData />` for empty data | Req 2.5 |
| `AgingChart` renders ChartCard with correct title | Req 2.3 |
| `WaterfallChart` renders `<NoData />` for empty data | Req 4.5 |
| `WaterfallChart` renders ChartCard with correct title | Req 4.6 |
### Integration Tests
| Test | Validates |
|------|-----------|
| Dashboard fetches `/compliance/mttr` and `/compliance/top-recurring` on mount | Req 5.1, 5.2 |
| Dashboard still fetches `/compliance/trends` and `/archer-tickets/status-trend` | Req 5.3 |
| All four fetches run in parallel via `Promise.all` | Req 5.4 |
| Single fetch failure does not break other charts | Req 5.5 |
### Smoke Tests
| Test | Validates |
|------|-----------|
| `/compliance/mttr` route responds and does NOT return old `{ mttr: [...] }` shape | Req 1.6 |
| `/compliance/top-recurring` route responds and does NOT return old `{ items: [...] }` shape | Req 3.7 |
### Testability Design Note
To enable property-based testing, the bucketing logic and waterfall accumulator will be extracted as **pure exported functions** (e.g., `bucketAgingItems(items)` and `computeWaterfall(uploads)`) separate from the Express route handlers. This allows the property tests to call the pure functions directly without needing HTTP or database mocking.